detached 90ecd5a

feat(pam): add purego Linux-PAM binding with managed conversation

commit
90ecd5aafbb218deb83cead14abd571e9db23e02
author
brice · · 2026-09-30 19:39 UTC
committer
brice · · 2026-09-30 19:51 UTC
parent
0b4c4c3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +2281 -0
A .gitignore +2 -0
@@ -0,0 +1,2 @@
1+.worktrees/
2+*.test
@@ -0,0 +1,2 @@
1+.worktrees/
2+*.test
A .mockery.yml +25 -0
@@ -0,0 +1,25 @@
1+all: false
2+formatter: goimports
3+force-file-write: true
4+structname: '{{.Mock}}{{.InterfaceName}}'
5+template: testify
6+template-schema: '{{.Template}}.schema.json'
7+require-template-schema-exists: true
8+packages:
9+ github.com/bnema/purego-pam:
10+ interfaces:
11+ libraryFunctions:
12+ config:
13+ dir: .
14+ pkgname: pam
15+ filename: library_functions_mock_test.go
16+ Conversation:
17+ config:
18+ dir: .
19+ pkgname: pam
20+ filename: conversation_mock_test.go
21+ nativeMemory:
22+ config:
23+ dir: .
24+ pkgname: pam
25+ filename: native_memory_mock_test.go
@@ -0,0 +1,25 @@
1+all: false
2+formatter: goimports
3+force-file-write: true
4+structname: '{{.Mock}}{{.InterfaceName}}'
5+template: testify
6+template-schema: '{{.Template}}.schema.json'
7+require-template-schema-exists: true
8+packages:
9+ github.com/bnema/purego-pam:
10+ interfaces:
11+ libraryFunctions:
12+ config:
13+ dir: .
14+ pkgname: pam
15+ filename: library_functions_mock_test.go
16+ Conversation:
17+ config:
18+ dir: .
19+ pkgname: pam
20+ filename: conversation_mock_test.go
21+ nativeMemory:
22+ config:
23+ dir: .
24+ pkgname: pam
25+ filename: native_memory_mock_test.go
A AGENTS.md +7 -0
@@ -0,0 +1,7 @@
1+# Repository rules
2+
3+- Go 1.27; builds and tests use CGO_ENABLED=0. Race tests use CGO_ENABLED=1.
4+- Mocks are Mockery v3 generated only; no handwritten fakes or doubles.
5+- Native memory: PAM answers are allocated with libc malloc and wiped before free. Prompt and answer buffers are wiped on every path.
6+- Commits are signed and follow Conventional Commits.
7+- Run `make check` and `make race` before merge.
@@ -0,0 +1,7 @@
1+# Repository rules
2+
3+- Go 1.27; builds and tests use CGO_ENABLED=0. Race tests use CGO_ENABLED=1.
4+- Mocks are Mockery v3 generated only; no handwritten fakes or doubles.
5+- Native memory: PAM answers are allocated with libc malloc and wiped before free. Prompt and answer buffers are wiped on every path.
6+- Commits are signed and follow Conventional Commits.
7+- Run `make check` and `make race` before merge.
A LICENSE +21 -0
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 purego-pam contributors
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 purego-pam contributors
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
A Makefile +27 -0
@@ -0,0 +1,27 @@
1+export GOPROXY := off
2+export GOSUMDB := off
3+
4+.PHONY: check test build race mocks mocks-check vet
5+
6+check: vet test build mocks-check
7+ CGO_ENABLED=0 staticcheck ./...
8+
9+test:
10+ CGO_ENABLED=0 go test -count=1 ./...
11+
12+build:
13+ CGO_ENABLED=0 go build ./...
14+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./...
15+
16+# Go's race detector requires cgo; this is a separate diagnostic build.
17+race:
18+ CGO_ENABLED=1 go test -race -count=1 ./...
19+
20+vet:
21+ CGO_ENABLED=0 go vet ./...
22+
23+mocks:
24+ CGO_ENABLED=0 mockery --config .mockery.yml
25+
26+mocks-check:
27+ CGO_ENABLED=0 mockery --config .mockery.yml && git diff --exit-code -- '*_mock_test.go'
@@ -0,0 +1,27 @@
1+export GOPROXY := off
2+export GOSUMDB := off
3+
4+.PHONY: check test build race mocks mocks-check vet
5+
6+check: vet test build mocks-check
7+ CGO_ENABLED=0 staticcheck ./...
8+
9+test:
10+ CGO_ENABLED=0 go test -count=1 ./...
11+
12+build:
13+ CGO_ENABLED=0 go build ./...
14+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./...
15+
16+# Go's race detector requires cgo; this is a separate diagnostic build.
17+race:
18+ CGO_ENABLED=1 go test -race -count=1 ./...
19+
20+vet:
21+ CGO_ENABLED=0 go vet ./...
22+
23+mocks:
24+ CGO_ENABLED=0 mockery --config .mockery.yml
25+
26+mocks-check:
27+ CGO_ENABLED=0 mockery --config .mockery.yml && git diff --exit-code -- '*_mock_test.go'
A README.md +108 -0
@@ -0,0 +1,108 @@
1+# purego-pam
2+
3+Linux-PAM binding for **Go 1.27**, normally built with
4+**CGO_ENABLED=0**, using `github.com/bnema/purego`. Supported ABI targets:
5+Linux amd64 and arm64 (LP64, 32-bit C `int`). This is not an authentication
6+product.
7+
8+## API
9+
10+- `Open() (*Library, error)` loads system `libpam.so.0` and `libc.so.6` with
11+ eager, local symbol resolution. Binding failure returns an error.
12+- `Library.Start(service, user, NativeConversation)` requires an explicit
13+ nonempty service and a native conversation function. Empty user becomes
14+ NULL; embedded NULs are rejected.
15+- `Library.StartConversation(service, user, Conversation)` adds the managed Go
16+ conversation bridge with library-owned answer buffers.
17+- `Transaction.Authenticate(flags)` and `AcctMgmt(flags)` return nil only for
18+ PAM_SUCCESS. Every other status produces a typed `*Error`. Supported flags
19+ are SILENT and DISALLOW_NULL_AUTHTOK; other bits are rejected.
20+- `Transaction.End()` passes the last operation status, consumes the
21+ transaction even on error, and releases native storage after `pam_end`
22+ returns. Reuse is rejected.
23+- `Library.Close()` rejects active transactions before unloading symbols.
24+
25+Neither Library nor Transaction may be copied. Their zero values are closed.
26+All operations on a Library and its Transactions require serialized caller
27+ownership. Native calls are synchronous and may block; there is no timeout,
28+cancellation, or thread-affinity guarantee.
29+
30+Authentication success alone is not account authorization. Callers must check
31+both Authenticate and AcctMgmt; End is cleanup, not permission to grant access.
32+The library does not install or validate PAM service policy. System fallback
33+configuration may apply when the named service is absent.
34+
35+## Native memory boundary
36+
37+Start copies service/user strings into libc-owned memory and allocates the
38+conversation structure and handle output slot there. Storage remains valid
39+through End. On failed Start, the undefined output handle is never read or
40+passed to `pam_end`. Successful Start with a NULL handle fails closed.
41+
42+`NativeConversation` is a **trusted unsafe boundary**, not a safe Go callback
43+API. `Function` must be a valid C-ABI conversation function address. `Data`
44+must be NULL or caller-owned native memory, never a Go pointer. The caller
45+must keep both valid until End returns; the type system cannot validate these
46+addresses. This external API is distinct from the managed bridge below.
47+
48+## Managed conversation bridge
49+
50+`Conversation.Respond(Message, answer []byte) (length int, err error)` receives
51+copied message bytes and a zeroed 512-byte answer buffer. Write a non-NUL answer
52+and return its byte length; informational messages require length zero. Do not
53+retain buffers, log their contents, call transaction/library methods from the
54+handler, or recursively invoke its conversation. Handler calls are serialized
55+per conversation, including concurrent native entries. A handler must return;
56+End can wait indefinitely for a blocked handler.
57+
58+The bridge handles 1..32 Linux-PAM messages, represented as an array of message
59+pointers. It accepts ECHO_OFF, ECHO_ON, ERROR_MSG and TEXT_INFO only. Message text
60+must be NUL-terminated within 512 bytes; answers may contain at most 512 bytes.
61+NULL and alignment checks do not make arbitrary native addresses safe: PAM
62+modules must supply readable message memory and writable output slots.
63+
64+One shared C-ABI callback lives for the process lifetime and is never
65+unreferenced. Application data contains a nonreused numeric opaque cookie,
66+**not a memory address**; neither PAM nor the bridge may dereference it. Unknown
67+or retired cookies return CONV_ERR without touching message/output memory.
68+Registry admission remains open during `pam_end`, then closes and waits for
69+admitted callbacks before transaction storage is freed. Native modules that
70+continue callbacks after `pam_end` violate the normal lifetime contract;
71+retired cookies reject their calls, but this does not validate their behavior.
72+
73+Responses are a contiguous libc-owned array with zero return codes and
74+libc-owned strings for prompts. Successful return transfers ownership to the
75+PAM module, which must free both. Failure publishes no response array and wipes
76+and frees partial native answer allocations. Go prompt and answer buffers are
77+wiped on success, handler error, and handler panic. Errors and panic payloads
78+are not logged or returned. Handler-private copies and successful native
79+responses after ownership transfer remain outside the library's wiping control.
80+
81+## Development
82+
83+Production code depends on `github.com/bnema/purego` and the standard library. Tests use Mockery v3's
84+generated testify mocks of PAM calls, conversation handlers and native memory
85+through `EXPECT()` and
86+`RunAndReturn`, alongside real libc allocations.
87+
88+```sh
89+make check # vet, test, build (incl. arm64), mocks-check, staticcheck
90+make race # separate CGO1 race diagnostic
91+```
92+
93+Make targets use offline module resolution. Go requires CGO_ENABLED=1 for
94+`-race`; no C helper is included. Tests require installed system libpam/libc
95+and resolve PAM symbols, but PAM transaction calls go only to generated
96+mocks. Synthetic `purego.SyscallN` calls exercise the actual callback ABI,
97+including failure cleanup and callback retirement. Tests use no real
98+authentication or credentials. Native layout sizes,
99+offsets, and alignment have compile-time assertions for both targets; arm64
100+native calls remain unverified without a matching libc/libpam sysroot.
101+
102+Not included: PAM service installation, CLI,
103+worker process, defaults, setuid, shadow/password handling, sessions,
104+credential establishment, or password changes.
105+
106+## License
107+
108+MIT. See LICENSE.
@@ -0,0 +1,108 @@
1+# purego-pam
2+
3+Linux-PAM binding for **Go 1.27**, normally built with
4+**CGO_ENABLED=0**, using `github.com/bnema/purego`. Supported ABI targets:
5+Linux amd64 and arm64 (LP64, 32-bit C `int`). This is not an authentication
6+product.
7+
8+## API
9+
10+- `Open() (*Library, error)` loads system `libpam.so.0` and `libc.so.6` with
11+ eager, local symbol resolution. Binding failure returns an error.
12+- `Library.Start(service, user, NativeConversation)` requires an explicit
13+ nonempty service and a native conversation function. Empty user becomes
14+ NULL; embedded NULs are rejected.
15+- `Library.StartConversation(service, user, Conversation)` adds the managed Go
16+ conversation bridge with library-owned answer buffers.
17+- `Transaction.Authenticate(flags)` and `AcctMgmt(flags)` return nil only for
18+ PAM_SUCCESS. Every other status produces a typed `*Error`. Supported flags
19+ are SILENT and DISALLOW_NULL_AUTHTOK; other bits are rejected.
20+- `Transaction.End()` passes the last operation status, consumes the
21+ transaction even on error, and releases native storage after `pam_end`
22+ returns. Reuse is rejected.
23+- `Library.Close()` rejects active transactions before unloading symbols.
24+
25+Neither Library nor Transaction may be copied. Their zero values are closed.
26+All operations on a Library and its Transactions require serialized caller
27+ownership. Native calls are synchronous and may block; there is no timeout,
28+cancellation, or thread-affinity guarantee.
29+
30+Authentication success alone is not account authorization. Callers must check
31+both Authenticate and AcctMgmt; End is cleanup, not permission to grant access.
32+The library does not install or validate PAM service policy. System fallback
33+configuration may apply when the named service is absent.
34+
35+## Native memory boundary
36+
37+Start copies service/user strings into libc-owned memory and allocates the
38+conversation structure and handle output slot there. Storage remains valid
39+through End. On failed Start, the undefined output handle is never read or
40+passed to `pam_end`. Successful Start with a NULL handle fails closed.
41+
42+`NativeConversation` is a **trusted unsafe boundary**, not a safe Go callback
43+API. `Function` must be a valid C-ABI conversation function address. `Data`
44+must be NULL or caller-owned native memory, never a Go pointer. The caller
45+must keep both valid until End returns; the type system cannot validate these
46+addresses. This external API is distinct from the managed bridge below.
47+
48+## Managed conversation bridge
49+
50+`Conversation.Respond(Message, answer []byte) (length int, err error)` receives
51+copied message bytes and a zeroed 512-byte answer buffer. Write a non-NUL answer
52+and return its byte length; informational messages require length zero. Do not
53+retain buffers, log their contents, call transaction/library methods from the
54+handler, or recursively invoke its conversation. Handler calls are serialized
55+per conversation, including concurrent native entries. A handler must return;
56+End can wait indefinitely for a blocked handler.
57+
58+The bridge handles 1..32 Linux-PAM messages, represented as an array of message
59+pointers. It accepts ECHO_OFF, ECHO_ON, ERROR_MSG and TEXT_INFO only. Message text
60+must be NUL-terminated within 512 bytes; answers may contain at most 512 bytes.
61+NULL and alignment checks do not make arbitrary native addresses safe: PAM
62+modules must supply readable message memory and writable output slots.
63+
64+One shared C-ABI callback lives for the process lifetime and is never
65+unreferenced. Application data contains a nonreused numeric opaque cookie,
66+**not a memory address**; neither PAM nor the bridge may dereference it. Unknown
67+or retired cookies return CONV_ERR without touching message/output memory.
68+Registry admission remains open during `pam_end`, then closes and waits for
69+admitted callbacks before transaction storage is freed. Native modules that
70+continue callbacks after `pam_end` violate the normal lifetime contract;
71+retired cookies reject their calls, but this does not validate their behavior.
72+
73+Responses are a contiguous libc-owned array with zero return codes and
74+libc-owned strings for prompts. Successful return transfers ownership to the
75+PAM module, which must free both. Failure publishes no response array and wipes
76+and frees partial native answer allocations. Go prompt and answer buffers are
77+wiped on success, handler error, and handler panic. Errors and panic payloads
78+are not logged or returned. Handler-private copies and successful native
79+responses after ownership transfer remain outside the library's wiping control.
80+
81+## Development
82+
83+Production code depends on `github.com/bnema/purego` and the standard library. Tests use Mockery v3's
84+generated testify mocks of PAM calls, conversation handlers and native memory
85+through `EXPECT()` and
86+`RunAndReturn`, alongside real libc allocations.
87+
88+```sh
89+make check # vet, test, build (incl. arm64), mocks-check, staticcheck
90+make race # separate CGO1 race diagnostic
91+```
92+
93+Make targets use offline module resolution. Go requires CGO_ENABLED=1 for
94+`-race`; no C helper is included. Tests require installed system libpam/libc
95+and resolve PAM symbols, but PAM transaction calls go only to generated
96+mocks. Synthetic `purego.SyscallN` calls exercise the actual callback ABI,
97+including failure cleanup and callback retirement. Tests use no real
98+authentication or credentials. Native layout sizes,
99+offsets, and alignment have compile-time assertions for both targets; arm64
100+native calls remain unverified without a matching libc/libpam sysroot.
101+
102+Not included: PAM service installation, CLI,
103+worker process, defaults, setuid, shadow/password handling, sessions,
104+credential establishment, or password changes.
105+
106+## License
107+
108+MIT. See LICENSE.
A abi_linux.go +123 -0
@@ -0,0 +1,123 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import "unsafe"
6+
7+// Status is a Linux-PAM C int. Only Success indicates success.
8+type Status int32
9+
10+// Linux-PAM statuses from security/_pam_types.h.
11+const (
12+ Success Status = iota
13+ OpenErr
14+ SymbolErr
15+ ServiceErr
16+ SystemErr
17+ BufErr
18+ PermDenied
19+ AuthErr
20+ CredInsufficient
21+ AuthinfoUnavail
22+ UserUnknown
23+ Maxtries
24+ NewAuthtokReqd
25+ AcctExpired
26+ SessionErr
27+ CredUnavail
28+ CredExpired
29+ CredErr
30+ NoModuleData
31+ ConvErr
32+ AuthtokErr
33+ AuthtokRecoveryErr
34+ AuthtokLockBusy
35+ AuthtokDisableAging
36+ TryAgain
37+ Ignore
38+ Abort
39+ AuthtokExpired
40+ ModuleUnknown
41+ BadItem
42+ ConvAgain
43+ Incomplete
44+)
45+
46+// Flags is a Linux-PAM C int.
47+type Flags int32
48+
49+const (
50+ Silent Flags = 0x8000
51+ DisallowNullAuthtok Flags = 0x0001
52+ PromptEchoOff int32 = 1
53+ PromptEchoOn int32 = 2
54+ ErrorMsg int32 = 3
55+ TextInfo int32 = 4
56+ MaxNumMsg = 32
57+ MaxMsgSize = 512
58+ MaxRespSize = 512
59+)
60+
61+// NativeConversation describes a caller-owned C conversation function and native
62+// application data. Function must implement:
63+// int conv(int, const struct pam_message **, struct pam_response **, void *).
64+// Data must be nil or libc-owned memory, NEVER a Go pointer. Function and Data
65+// must remain valid through End, including pam_end's cleanup callbacks.
66+// This low-level contract is unsafe by design; use StartConversation for the
67+// managed bridge instead.
68+type NativeConversation struct {
69+ Function uintptr
70+ Data unsafe.Pointer
71+}
72+
73+// Native Linux LP64 layouts; C int is 32 bits and pointers are 64 bits.
74+type nativeMessage struct {
75+ Style int32
76+ _ [4]byte
77+ Text unsafe.Pointer
78+}
79+
80+type nativeResponse struct {
81+ Text unsafe.Pointer
82+ ReturnCode int32
83+ _ [4]byte
84+}
85+
86+type nativeConversation struct {
87+ Function uintptr
88+ Data unsafe.Pointer
89+}
90+
91+// nativeCookieConversation has the same C layout, but treats appdata as an
92+// opaque integer value, never as an address or a Go unsafe.Pointer.
93+type nativeCookieConversation struct {
94+ Function uintptr
95+ Cookie uintptr
96+}
97+
98+// Both supported ABIs require these sizes, offsets and alignments. A mismatch
99+// fails compilation, including on the arm64 cross-build without a native host.
100+var (
101+ _ [16 - unsafe.Sizeof(nativeCookieConversation{})]byte
102+ _ [unsafe.Sizeof(nativeCookieConversation{}) - 16]byte
103+ _ [8 - unsafe.Offsetof(nativeCookieConversation{}.Cookie)]byte
104+ _ [unsafe.Offsetof(nativeCookieConversation{}.Cookie) - 8]byte
105+ _ [16 - unsafe.Sizeof(nativeMessage{})]byte
106+ _ [unsafe.Sizeof(nativeMessage{}) - 16]byte
107+ _ [16 - unsafe.Sizeof(nativeResponse{})]byte
108+ _ [unsafe.Sizeof(nativeResponse{}) - 16]byte
109+ _ [16 - unsafe.Sizeof(nativeConversation{})]byte
110+ _ [unsafe.Sizeof(nativeConversation{}) - 16]byte
111+ _ [8 - unsafe.Offsetof(nativeMessage{}.Text)]byte
112+ _ [unsafe.Offsetof(nativeMessage{}.Text) - 8]byte
113+ _ [8 - unsafe.Offsetof(nativeResponse{}.ReturnCode)]byte
114+ _ [unsafe.Offsetof(nativeResponse{}.ReturnCode) - 8]byte
115+ _ [8 - unsafe.Offsetof(nativeConversation{}.Data)]byte
116+ _ [unsafe.Offsetof(nativeConversation{}.Data) - 8]byte
117+ _ [8 - unsafe.Alignof(nativeMessage{})]byte
118+ _ [unsafe.Alignof(nativeMessage{}) - 8]byte
119+ _ [8 - unsafe.Alignof(nativeResponse{})]byte
120+ _ [unsafe.Alignof(nativeResponse{}) - 8]byte
121+ _ [8 - unsafe.Alignof(nativeConversation{})]byte
122+ _ [unsafe.Alignof(nativeConversation{}) - 8]byte
123+)
@@ -0,0 +1,123 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import "unsafe"
6+
7+// Status is a Linux-PAM C int. Only Success indicates success.
8+type Status int32
9+
10+// Linux-PAM statuses from security/_pam_types.h.
11+const (
12+ Success Status = iota
13+ OpenErr
14+ SymbolErr
15+ ServiceErr
16+ SystemErr
17+ BufErr
18+ PermDenied
19+ AuthErr
20+ CredInsufficient
21+ AuthinfoUnavail
22+ UserUnknown
23+ Maxtries
24+ NewAuthtokReqd
25+ AcctExpired
26+ SessionErr
27+ CredUnavail
28+ CredExpired
29+ CredErr
30+ NoModuleData
31+ ConvErr
32+ AuthtokErr
33+ AuthtokRecoveryErr
34+ AuthtokLockBusy
35+ AuthtokDisableAging
36+ TryAgain
37+ Ignore
38+ Abort
39+ AuthtokExpired
40+ ModuleUnknown
41+ BadItem
42+ ConvAgain
43+ Incomplete
44+)
45+
46+// Flags is a Linux-PAM C int.
47+type Flags int32
48+
49+const (
50+ Silent Flags = 0x8000
51+ DisallowNullAuthtok Flags = 0x0001
52+ PromptEchoOff int32 = 1
53+ PromptEchoOn int32 = 2
54+ ErrorMsg int32 = 3
55+ TextInfo int32 = 4
56+ MaxNumMsg = 32
57+ MaxMsgSize = 512
58+ MaxRespSize = 512
59+)
60+
61+// NativeConversation describes a caller-owned C conversation function and native
62+// application data. Function must implement:
63+// int conv(int, const struct pam_message **, struct pam_response **, void *).
64+// Data must be nil or libc-owned memory, NEVER a Go pointer. Function and Data
65+// must remain valid through End, including pam_end's cleanup callbacks.
66+// This low-level contract is unsafe by design; use StartConversation for the
67+// managed bridge instead.
68+type NativeConversation struct {
69+ Function uintptr
70+ Data unsafe.Pointer
71+}
72+
73+// Native Linux LP64 layouts; C int is 32 bits and pointers are 64 bits.
74+type nativeMessage struct {
75+ Style int32
76+ _ [4]byte
77+ Text unsafe.Pointer
78+}
79+
80+type nativeResponse struct {
81+ Text unsafe.Pointer
82+ ReturnCode int32
83+ _ [4]byte
84+}
85+
86+type nativeConversation struct {
87+ Function uintptr
88+ Data unsafe.Pointer
89+}
90+
91+// nativeCookieConversation has the same C layout, but treats appdata as an
92+// opaque integer value, never as an address or a Go unsafe.Pointer.
93+type nativeCookieConversation struct {
94+ Function uintptr
95+ Cookie uintptr
96+}
97+
98+// Both supported ABIs require these sizes, offsets and alignments. A mismatch
99+// fails compilation, including on the arm64 cross-build without a native host.
100+var (
101+ _ [16 - unsafe.Sizeof(nativeCookieConversation{})]byte
102+ _ [unsafe.Sizeof(nativeCookieConversation{}) - 16]byte
103+ _ [8 - unsafe.Offsetof(nativeCookieConversation{}.Cookie)]byte
104+ _ [unsafe.Offsetof(nativeCookieConversation{}.Cookie) - 8]byte
105+ _ [16 - unsafe.Sizeof(nativeMessage{})]byte
106+ _ [unsafe.Sizeof(nativeMessage{}) - 16]byte
107+ _ [16 - unsafe.Sizeof(nativeResponse{})]byte
108+ _ [unsafe.Sizeof(nativeResponse{}) - 16]byte
109+ _ [16 - unsafe.Sizeof(nativeConversation{})]byte
110+ _ [unsafe.Sizeof(nativeConversation{}) - 16]byte
111+ _ [8 - unsafe.Offsetof(nativeMessage{}.Text)]byte
112+ _ [unsafe.Offsetof(nativeMessage{}.Text) - 8]byte
113+ _ [8 - unsafe.Offsetof(nativeResponse{}.ReturnCode)]byte
114+ _ [unsafe.Offsetof(nativeResponse{}.ReturnCode) - 8]byte
115+ _ [8 - unsafe.Offsetof(nativeConversation{}.Data)]byte
116+ _ [unsafe.Offsetof(nativeConversation{}.Data) - 8]byte
117+ _ [8 - unsafe.Alignof(nativeMessage{})]byte
118+ _ [unsafe.Alignof(nativeMessage{}) - 8]byte
119+ _ [8 - unsafe.Alignof(nativeResponse{})]byte
120+ _ [unsafe.Alignof(nativeResponse{}) - 8]byte
121+ _ [8 - unsafe.Alignof(nativeConversation{})]byte
122+ _ [unsafe.Alignof(nativeConversation{}) - 8]byte
123+)
A binding_linux.go +76 -0
@@ -0,0 +1,76 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "fmt"
7+ "unsafe"
8+
9+ "github.com/bnema/purego"
10+)
11+
12+// libraryFunctions is the test seam; tests use Mockery, never replace functions.
13+type libraryFunctions interface {
14+ Start(service, user, conversation, output unsafe.Pointer) Status
15+ Authenticate(handle unsafe.Pointer, flags Flags) Status
16+ AcctMgmt(handle unsafe.Pointer, flags Flags) Status
17+ End(handle unsafe.Pointer, status Status) Status
18+}
19+
20+type boundFunctions struct {
21+ start func(unsafe.Pointer, unsafe.Pointer, unsafe.Pointer, unsafe.Pointer) Status
22+ authenticate func(unsafe.Pointer, Flags) Status
23+ acctMgmt func(unsafe.Pointer, Flags) Status
24+ end func(unsafe.Pointer, Status) Status
25+}
26+
27+func (b *boundFunctions) Start(s, u, c, o unsafe.Pointer) Status { return b.start(s, u, c, o) }
28+func (b *boundFunctions) Authenticate(h unsafe.Pointer, f Flags) Status { return b.authenticate(h, f) }
29+func (b *boundFunctions) AcctMgmt(h unsafe.Pointer, f Flags) Status { return b.acctMgmt(h, f) }
30+func (b *boundFunctions) End(h unsafe.Pointer, s Status) Status { return b.end(h, s) }
31+
32+// Open loads the system libpam and libc with eager, local symbol resolution.
33+// It performs no PAM transaction or authentication. Library operations must be
34+// serialized by the caller; Close is rejected while transactions are active.
35+func Open() (*Library, error) {
36+ pamHandle, err := purego.Dlopen("libpam.so.0", purego.RTLD_NOW|purego.RTLD_LOCAL)
37+ if err != nil {
38+ return nil, fmt.Errorf("pam: load libpam: %w", err)
39+ }
40+ libcHandle, err := purego.Dlopen("libc.so.6", purego.RTLD_NOW|purego.RTLD_LOCAL)
41+ if err != nil {
42+ return nil, fmt.Errorf("pam: load libc: %w", errorsWithClose(err, pamHandle))
43+ }
44+ functions := &boundFunctions{}
45+ memory := &nativeAllocator{}
46+ bindings := []struct {
47+ handle uintptr
48+ name string
49+ target any
50+ }{
51+ {pamHandle, "pam_start", &functions.start},
52+ {pamHandle, "pam_authenticate", &functions.authenticate},
53+ {pamHandle, "pam_acct_mgmt", &functions.acctMgmt},
54+ {pamHandle, "pam_end", &functions.end},
55+ {libcHandle, "malloc", &memory.malloc},
56+ {libcHandle, "free", &memory.freeFn},
57+ }
58+ for _, binding := range bindings {
59+ if err := register(binding.target, binding.handle, binding.name); err != nil {
60+ return nil, errorsWithClose(errorsWithClose(err, libcHandle), pamHandle)
61+ }
62+ }
63+ return &Library{calls: functions, memory: memory, pamHandle: pamHandle, libcHandle: libcHandle}, nil
64+}
65+
66+// RegisterLibFunc panics on lookup/registration failure; turn that boundary into
67+// an Open error so a partially bound library is never exposed.
68+func register(target any, handle uintptr, name string) (err error) {
69+ defer func() {
70+ if recovered := recover(); recovered != nil {
71+ err = fmt.Errorf("pam: bind %s: %v", name, recovered)
72+ }
73+ }()
74+ purego.RegisterLibFunc(target, handle, name)
75+ return nil
76+}
@@ -0,0 +1,76 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "fmt"
7+ "unsafe"
8+
9+ "github.com/bnema/purego"
10+)
11+
12+// libraryFunctions is the test seam; tests use Mockery, never replace functions.
13+type libraryFunctions interface {
14+ Start(service, user, conversation, output unsafe.Pointer) Status
15+ Authenticate(handle unsafe.Pointer, flags Flags) Status
16+ AcctMgmt(handle unsafe.Pointer, flags Flags) Status
17+ End(handle unsafe.Pointer, status Status) Status
18+}
19+
20+type boundFunctions struct {
21+ start func(unsafe.Pointer, unsafe.Pointer, unsafe.Pointer, unsafe.Pointer) Status
22+ authenticate func(unsafe.Pointer, Flags) Status
23+ acctMgmt func(unsafe.Pointer, Flags) Status
24+ end func(unsafe.Pointer, Status) Status
25+}
26+
27+func (b *boundFunctions) Start(s, u, c, o unsafe.Pointer) Status { return b.start(s, u, c, o) }
28+func (b *boundFunctions) Authenticate(h unsafe.Pointer, f Flags) Status { return b.authenticate(h, f) }
29+func (b *boundFunctions) AcctMgmt(h unsafe.Pointer, f Flags) Status { return b.acctMgmt(h, f) }
30+func (b *boundFunctions) End(h unsafe.Pointer, s Status) Status { return b.end(h, s) }
31+
32+// Open loads the system libpam and libc with eager, local symbol resolution.
33+// It performs no PAM transaction or authentication. Library operations must be
34+// serialized by the caller; Close is rejected while transactions are active.
35+func Open() (*Library, error) {
36+ pamHandle, err := purego.Dlopen("libpam.so.0", purego.RTLD_NOW|purego.RTLD_LOCAL)
37+ if err != nil {
38+ return nil, fmt.Errorf("pam: load libpam: %w", err)
39+ }
40+ libcHandle, err := purego.Dlopen("libc.so.6", purego.RTLD_NOW|purego.RTLD_LOCAL)
41+ if err != nil {
42+ return nil, fmt.Errorf("pam: load libc: %w", errorsWithClose(err, pamHandle))
43+ }
44+ functions := &boundFunctions{}
45+ memory := &nativeAllocator{}
46+ bindings := []struct {
47+ handle uintptr
48+ name string
49+ target any
50+ }{
51+ {pamHandle, "pam_start", &functions.start},
52+ {pamHandle, "pam_authenticate", &functions.authenticate},
53+ {pamHandle, "pam_acct_mgmt", &functions.acctMgmt},
54+ {pamHandle, "pam_end", &functions.end},
55+ {libcHandle, "malloc", &memory.malloc},
56+ {libcHandle, "free", &memory.freeFn},
57+ }
58+ for _, binding := range bindings {
59+ if err := register(binding.target, binding.handle, binding.name); err != nil {
60+ return nil, errorsWithClose(errorsWithClose(err, libcHandle), pamHandle)
61+ }
62+ }
63+ return &Library{calls: functions, memory: memory, pamHandle: pamHandle, libcHandle: libcHandle}, nil
64+}
65+
66+// RegisterLibFunc panics on lookup/registration failure; turn that boundary into
67+// an Open error so a partially bound library is never exposed.
68+func register(target any, handle uintptr, name string) (err error) {
69+ defer func() {
70+ if recovered := recover(); recovered != nil {
71+ err = fmt.Errorf("pam: bind %s: %v", name, recovered)
72+ }
73+ }()
74+ purego.RegisterLibFunc(target, handle, name)
75+ return nil
76+}
A conversation_extra_linux_test.go +154 -0
@@ -0,0 +1,154 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "testing"
7+ "time"
8+ "unsafe"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestManagedStartFailureRetiresCookie(t *testing.T) {
15+ l, calls, _ := testLibrary(t)
16+ handler := NewMockConversation(t)
17+ var cookie uintptr
18+ calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_, _, conv, _ unsafe.Pointer) Status {
19+ cookie = (*nativeCookieConversation)(conv).Cookie
20+ return ServiceErr
21+ }).Once()
22+ tx, err := l.StartConversation("unit-service", "unit-user", handler)
23+ require.Error(t, err)
24+ require.Nil(t, tx)
25+ require.Nil(t, acquireConversation(cookie))
26+ require.Zero(t, l.active)
27+ _, err = l.StartConversation("unit-service", "unit-user", nil)
28+ require.ErrorIs(t, err, ErrInvalidArgument)
29+ _, err = l.StartConversation("", "unit-user", handler)
30+ require.ErrorIs(t, err, ErrInvalidArgument)
31+}
32+
33+func TestNativeEndCanInvokeManagedConversation(t *testing.T) {
34+ l, calls, handle := testLibrary(t)
35+ handler := NewMockConversation(t)
36+ messages, output := nativeMessages(t, l.memory, TextInfo)
37+ var cookie uintptr
38+ calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_, _, c, out unsafe.Pointer) Status {
39+ cookie = (*nativeCookieConversation)(c).Cookie
40+ *(*unsafe.Pointer)(out) = handle
41+ return Success
42+ }).Once()
43+ tx, err := l.StartConversation("unit-service", "", handler)
44+ require.NoError(t, err)
45+ handler.EXPECT().Respond(mock.Anything, mock.Anything).Return(0, nil).Once()
46+ calls.EXPECT().End(handle, Success).RunAndReturn(func(_ unsafe.Pointer, _ Status) Status {
47+ require.Equal(t, Success, invokeConversation(t, cookie, 1, messages, output))
48+ l.memory.free(*(*unsafe.Pointer)(output))
49+ return SystemErr
50+ }).Once()
51+ require.Error(t, tx.End())
52+ require.Nil(t, acquireConversation(cookie))
53+ require.Empty(t, tx.storage)
54+ require.Zero(t, l.active)
55+}
56+
57+func TestConcurrentEntriesAreSerializedAndRetirementWaits(t *testing.T) {
58+ l, _, _ := testLibrary(t)
59+ handler := NewMockConversation(t)
60+ s := callbackState(t, handler, l.memory)
61+ messages, output1 := nativeMessages(t, l.memory, TextInfo)
62+ _, output2 := nativeMessages(t, l.memory, TextInfo)
63+ entered, release := make(chan struct{}), make(chan struct{})
64+ second := make(chan struct{}, 1)
65+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, _ []byte) (int, error) {
66+ close(entered)
67+ <-release
68+ return 0, nil
69+ }).Once()
70+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, _ []byte) (int, error) {
71+ second <- struct{}{}
72+ return 0, nil
73+ }).Once()
74+ done1, done2 := make(chan Status, 1), make(chan Status, 1)
75+ go func() { done1 <- invokeConversation(t, s.token, 1, messages, output1) }()
76+ select {
77+ case <-entered:
78+ case <-time.After(3 * time.Second):
79+ t.Fatal("first callback not entered")
80+ }
81+ // Admit a second entry explicitly; it holds a registry reference until its
82+ // callback finishes, so retirement must wait even if it closes admission.
83+ admitted := acquireConversation(s.token)
84+ require.NotNil(t, admitted)
85+ go func() {
86+ done2 <- invokeConversation(t, s.token, 1, messages, output2)
87+ admitted.leave()
88+ }()
89+ select {
90+ case <-second:
91+ t.Fatal("handler reentered while first was active")
92+ default:
93+ }
94+ // Do not retire until both actual callbacks finished: native calls above
95+ // exercise serialization, while explicit admitted reference tests the wait.
96+ close(release)
97+ select {
98+ case status := <-done1:
99+ require.Equal(t, Success, status)
100+ case <-time.After(3 * time.Second):
101+ t.Fatal("first callback blocked")
102+ }
103+ select {
104+ case status := <-done2:
105+ require.Equal(t, Success, status)
106+ case <-time.After(3 * time.Second):
107+ t.Fatal("second callback blocked")
108+ }
109+ for _, output := range []unsafe.Pointer{output1, output2} {
110+ l.memory.free(*(*unsafe.Pointer)(output))
111+ }
112+ held := acquireConversation(s.token)
113+ require.NotNil(t, held)
114+ retired := make(chan struct{})
115+ go func() { s.retire(); close(retired) }()
116+ select {
117+ case <-retired:
118+ t.Fatal("retirement ignored admitted reference")
119+ default:
120+ }
121+ held.leave()
122+ select {
123+ case <-retired:
124+ case <-time.After(3 * time.Second):
125+ t.Fatal("retirement did not finish")
126+ }
127+ require.Nil(t, acquireConversation(s.token))
128+}
129+
130+func TestMaximumAnswerAndMessageCount(t *testing.T) {
131+ l, _, _ := testLibrary(t)
132+ handler := NewMockConversation(t)
133+ s := callbackState(t, handler, l.memory)
134+ styles := make([]int32, MaxNumMsg)
135+ for i := range styles {
136+ styles[i] = PromptEchoOff
137+ }
138+ messages, output := nativeMessages(t, l.memory, styles...)
139+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, answer []byte) (int, error) {
140+ for i := range answer {
141+ answer[i] = 'x'
142+ }
143+ return MaxRespSize, nil
144+ }).Times(MaxNumMsg)
145+ require.Equal(t, Success, invokeConversation(t, s.token, MaxNumMsg, messages, output))
146+ p := *(*unsafe.Pointer)(output)
147+ for _, response := range unsafe.Slice((*nativeResponse)(p), MaxNumMsg) {
148+ bytes := unsafe.Slice((*byte)(response.Text), MaxRespSize+1)
149+ require.Equal(t, byte('x'), bytes[MaxRespSize-1])
150+ require.Zero(t, bytes[MaxRespSize])
151+ l.memory.free(response.Text)
152+ }
153+ l.memory.free(p)
154+}
@@ -0,0 +1,154 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "testing"
7+ "time"
8+ "unsafe"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestManagedStartFailureRetiresCookie(t *testing.T) {
15+ l, calls, _ := testLibrary(t)
16+ handler := NewMockConversation(t)
17+ var cookie uintptr
18+ calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_, _, conv, _ unsafe.Pointer) Status {
19+ cookie = (*nativeCookieConversation)(conv).Cookie
20+ return ServiceErr
21+ }).Once()
22+ tx, err := l.StartConversation("unit-service", "unit-user", handler)
23+ require.Error(t, err)
24+ require.Nil(t, tx)
25+ require.Nil(t, acquireConversation(cookie))
26+ require.Zero(t, l.active)
27+ _, err = l.StartConversation("unit-service", "unit-user", nil)
28+ require.ErrorIs(t, err, ErrInvalidArgument)
29+ _, err = l.StartConversation("", "unit-user", handler)
30+ require.ErrorIs(t, err, ErrInvalidArgument)
31+}
32+
33+func TestNativeEndCanInvokeManagedConversation(t *testing.T) {
34+ l, calls, handle := testLibrary(t)
35+ handler := NewMockConversation(t)
36+ messages, output := nativeMessages(t, l.memory, TextInfo)
37+ var cookie uintptr
38+ calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_, _, c, out unsafe.Pointer) Status {
39+ cookie = (*nativeCookieConversation)(c).Cookie
40+ *(*unsafe.Pointer)(out) = handle
41+ return Success
42+ }).Once()
43+ tx, err := l.StartConversation("unit-service", "", handler)
44+ require.NoError(t, err)
45+ handler.EXPECT().Respond(mock.Anything, mock.Anything).Return(0, nil).Once()
46+ calls.EXPECT().End(handle, Success).RunAndReturn(func(_ unsafe.Pointer, _ Status) Status {
47+ require.Equal(t, Success, invokeConversation(t, cookie, 1, messages, output))
48+ l.memory.free(*(*unsafe.Pointer)(output))
49+ return SystemErr
50+ }).Once()
51+ require.Error(t, tx.End())
52+ require.Nil(t, acquireConversation(cookie))
53+ require.Empty(t, tx.storage)
54+ require.Zero(t, l.active)
55+}
56+
57+func TestConcurrentEntriesAreSerializedAndRetirementWaits(t *testing.T) {
58+ l, _, _ := testLibrary(t)
59+ handler := NewMockConversation(t)
60+ s := callbackState(t, handler, l.memory)
61+ messages, output1 := nativeMessages(t, l.memory, TextInfo)
62+ _, output2 := nativeMessages(t, l.memory, TextInfo)
63+ entered, release := make(chan struct{}), make(chan struct{})
64+ second := make(chan struct{}, 1)
65+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, _ []byte) (int, error) {
66+ close(entered)
67+ <-release
68+ return 0, nil
69+ }).Once()
70+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, _ []byte) (int, error) {
71+ second <- struct{}{}
72+ return 0, nil
73+ }).Once()
74+ done1, done2 := make(chan Status, 1), make(chan Status, 1)
75+ go func() { done1 <- invokeConversation(t, s.token, 1, messages, output1) }()
76+ select {
77+ case <-entered:
78+ case <-time.After(3 * time.Second):
79+ t.Fatal("first callback not entered")
80+ }
81+ // Admit a second entry explicitly; it holds a registry reference until its
82+ // callback finishes, so retirement must wait even if it closes admission.
83+ admitted := acquireConversation(s.token)
84+ require.NotNil(t, admitted)
85+ go func() {
86+ done2 <- invokeConversation(t, s.token, 1, messages, output2)
87+ admitted.leave()
88+ }()
89+ select {
90+ case <-second:
91+ t.Fatal("handler reentered while first was active")
92+ default:
93+ }
94+ // Do not retire until both actual callbacks finished: native calls above
95+ // exercise serialization, while explicit admitted reference tests the wait.
96+ close(release)
97+ select {
98+ case status := <-done1:
99+ require.Equal(t, Success, status)
100+ case <-time.After(3 * time.Second):
101+ t.Fatal("first callback blocked")
102+ }
103+ select {
104+ case status := <-done2:
105+ require.Equal(t, Success, status)
106+ case <-time.After(3 * time.Second):
107+ t.Fatal("second callback blocked")
108+ }
109+ for _, output := range []unsafe.Pointer{output1, output2} {
110+ l.memory.free(*(*unsafe.Pointer)(output))
111+ }
112+ held := acquireConversation(s.token)
113+ require.NotNil(t, held)
114+ retired := make(chan struct{})
115+ go func() { s.retire(); close(retired) }()
116+ select {
117+ case <-retired:
118+ t.Fatal("retirement ignored admitted reference")
119+ default:
120+ }
121+ held.leave()
122+ select {
123+ case <-retired:
124+ case <-time.After(3 * time.Second):
125+ t.Fatal("retirement did not finish")
126+ }
127+ require.Nil(t, acquireConversation(s.token))
128+}
129+
130+func TestMaximumAnswerAndMessageCount(t *testing.T) {
131+ l, _, _ := testLibrary(t)
132+ handler := NewMockConversation(t)
133+ s := callbackState(t, handler, l.memory)
134+ styles := make([]int32, MaxNumMsg)
135+ for i := range styles {
136+ styles[i] = PromptEchoOff
137+ }
138+ messages, output := nativeMessages(t, l.memory, styles...)
139+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, answer []byte) (int, error) {
140+ for i := range answer {
141+ answer[i] = 'x'
142+ }
143+ return MaxRespSize, nil
144+ }).Times(MaxNumMsg)
145+ require.Equal(t, Success, invokeConversation(t, s.token, MaxNumMsg, messages, output))
146+ p := *(*unsafe.Pointer)(output)
147+ for _, response := range unsafe.Slice((*nativeResponse)(p), MaxNumMsg) {
148+ bytes := unsafe.Slice((*byte)(response.Text), MaxRespSize+1)
149+ require.Equal(t, byte('x'), bytes[MaxRespSize-1])
150+ require.Zero(t, bytes[MaxRespSize])
151+ l.memory.free(response.Text)
152+ }
153+ l.memory.free(p)
154+}
A conversation_linux.go +294 -0
@@ -0,0 +1,294 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "errors"
7+ "sync"
8+ "unsafe"
9+
10+ "github.com/bnema/purego"
11+)
12+
13+// Message is a copied Linux-PAM message. Text may be sensitive and is wiped
14+// after Respond returns. Do not retain it; do not log prompts or answers.
15+type Message struct {
16+ Style int32
17+ Text []byte
18+}
19+
20+// Conversation supplies responses without retaining native or Go buffers.
21+// Respond receives a zeroed MaxRespSize-byte answer buffer. Write the answer
22+// there and return its length (0..MaxRespSize), without a NUL. For informational
23+// messages return zero. The library wipes Text and answer even on error/panic.
24+// Do not retain buffers or access them after return, or call Library/Transaction
25+// methods from Respond. Respond must return: no timeout cancels a native call.
26+// Calls for one conversation are serialized, including foreign-thread entry.
27+// Any private secret copies made by the handler remain its responsibility.
28+type Conversation interface {
29+ Respond(message Message, answer []byte) (int, error)
30+}
31+
32+// nativeMemory is the allocator seam for callback failure tests.
33+type nativeMemory interface {
34+ alloc(size uintptr) (unsafe.Pointer, error)
35+ free(pointer unsafe.Pointer)
36+}
37+
38+type conversationState struct {
39+ token uintptr
40+ handler Conversation
41+ memory nativeMemory
42+ serial sync.Mutex
43+ refs int // guarded by conversations.Mutex
44+}
45+
46+var conversations = struct {
47+ sync.Mutex
48+ next uintptr
49+ states map[uintptr]*conversationState
50+}{states: make(map[uintptr]*conversationState)}
51+var conversationChanged = sync.NewCond(&conversations.Mutex)
52+var sharedCallback struct {
53+ sync.Once
54+ address uintptr
55+ err error
56+}
57+
58+// The shared callback is never unreferenced: late calls can safely reject an
59+// unknown cookie without reading any appdata, message or output memory.
60+func callbackAddress() (uintptr, error) {
61+ sharedCallback.Do(func() {
62+ defer func() {
63+ if recover() != nil {
64+ sharedCallback.err = errors.New("pam: callback registration failed")
65+ }
66+ }()
67+ sharedCallback.address = purego.NewCallbackInts(conversationCallback)
68+ })
69+ return sharedCallback.address, sharedCallback.err
70+}
71+
72+func registerConversation(handler Conversation, memory nativeMemory) (*conversationState, error) {
73+ conversations.Lock()
74+ defer conversations.Unlock()
75+ if conversations.next == ^uintptr(0) {
76+ return nil, errors.New("pam: conversation cookie space exhausted")
77+ }
78+ conversations.next++ // never reuse a cookie, including after failed Start
79+ s := &conversationState{token: conversations.next, handler: handler, memory: memory}
80+ conversations.states[s.token] = s
81+ return s, nil
82+}
83+
84+func acquireConversation(token uintptr) *conversationState {
85+ conversations.Lock()
86+ defer conversations.Unlock()
87+ s := conversations.states[token]
88+ if s != nil {
89+ s.refs++
90+ }
91+ return s
92+}
93+
94+func (s *conversationState) leave() {
95+ conversations.Lock()
96+ s.refs--
97+ conversationChanged.Broadcast()
98+ conversations.Unlock()
99+}
100+
101+func (s *conversationState) retire() {
102+ conversations.Lock()
103+ delete(conversations.states, s.token) // close admission before waiting
104+ for s.refs != 0 {
105+ conversationChanged.Wait()
106+ }
107+ conversations.Unlock()
108+}
109+
110+// StartConversation adds the managed Go bridge; the external NativeConversation
111+// API is unchanged. appdata is a numeric opaque void* cookie, NOT a pointer to
112+// memory, and neither PAM nor the bridge may dereference it.
113+func (l *Library) StartConversation(service, user string, handler Conversation) (*Transaction, error) {
114+ if l == nil || l.closed || l.calls == nil || l.memory == nil {
115+ return nil, ErrClosed
116+ }
117+ if handler == nil {
118+ return nil, ErrInvalidArgument
119+ }
120+ address, err := callbackAddress()
121+ if err != nil {
122+ return nil, err
123+ }
124+ s, err := registerConversation(handler, l.memory)
125+ if err != nil {
126+ return nil, err
127+ }
128+ t, err := l.start(service, user, NativeConversation{Function: address}, s)
129+ if err != nil {
130+ s.retire()
131+ }
132+ return t, err
133+}
134+
135+// Raw integer-class entry keeps all native address bits out of Go pointer
136+// values until cookie admission. C int count uses only the low 32 bits.
137+func conversationCallback(args *purego.CallbackArgs) (result uintptr) {
138+ result = uintptr(ConvErr)
139+ defer func() {
140+ if recover() != nil {
141+ result = uintptr(ConvErr)
142+ }
143+ }()
144+ count, cookie := int32(args.Int(0)), args.Int(3)
145+ s := acquireConversation(cookie)
146+ if s == nil {
147+ return result
148+ }
149+ defer s.leave()
150+ messages, output := args.Int(1), args.Int(2)
151+ if count < 1 || count > MaxNumMsg || messages == 0 || output == 0 ||
152+ messages%unsafe.Alignof(uintptr(0)) != 0 || output%unsafe.Alignof(uintptr(0)) != 0 {
153+ return result
154+ }
155+ return uintptr(uint32(s.respondNative(count, admittedNativeAddress(messages), admittedNativeAddress(output))))
156+}
157+
158+// admittedNativeAddress reinterprets native address bits only after admission
159+// and basic validation. This is not an address-validity guarantee; the admitted
160+// native caller must supply readable/writable memory throughout the call.
161+func admittedNativeAddress(bits uintptr) unsafe.Pointer {
162+ return *(*unsafe.Pointer)(unsafe.Pointer(&bits))
163+}
164+
165+func (s *conversationState) respondNative(count int32, messages, output unsafe.Pointer) (status Status) {
166+ status = ConvErr
167+ s.serial.Lock()
168+ defer s.serial.Unlock()
169+ var responses unsafe.Pointer
170+ var lengths []int
171+ transferred := false
172+ defer func() {
173+ if recover() != nil {
174+ status = ConvErr
175+ } // never expose panic payload
176+ if responses != nil && !transferred {
177+ s.cleanupResponses(responses, lengths)
178+ }
179+ }()
180+ if count < 1 || count > MaxNumMsg || messages == nil || output == nil {
181+ return status
182+ }
183+ // Trusted native caller must supply readable arrays and writable output;
184+ // NULL checks and bounded reads cannot validate arbitrary native addresses.
185+ if uintptr(messages)%unsafe.Alignof(uintptr(0)) != 0 || uintptr(output)%unsafe.Alignof(uintptr(0)) != 0 {
186+ return status
187+ }
188+ *(*unsafe.Pointer)(output) = nil
189+ lengths = make([]int, int(count))
190+ var err error
191+ responses, err = s.memory.alloc(uintptr(count) * unsafe.Sizeof(nativeResponse{}))
192+ if err != nil {
193+ return BufErr
194+ }
195+ array := unsafe.Slice((*nativeResponse)(responses), int(count))
196+ pointers := unsafe.Slice((*unsafe.Pointer)(messages), int(count))
197+ for i, pointer := range pointers {
198+ if pointer == nil || uintptr(pointer)%unsafe.Alignof(nativeMessage{}) != 0 {
199+ return ConvErr
200+ }
201+ message := *(*nativeMessage)(pointer)
202+ if message.Style != PromptEchoOff && message.Style != PromptEchoOn && message.Style != ErrorMsg && message.Style != TextInfo {
203+ return ConvErr
204+ }
205+ text, ok := copyMessage(message.Text)
206+ if !ok {
207+ return ConvErr
208+ }
209+ answer, n, err := respond(s.handler, Message{Style: message.Style, Text: text})
210+ if err != nil {
211+ return ConvErr
212+ }
213+ // respond's caller owns wiping the returned answer after native copy.
214+ result := func() Status {
215+ defer clear(answer)
216+ if message.Style == ErrorMsg || message.Style == TextInfo {
217+ if n != 0 {
218+ return ConvErr
219+ }
220+ return Success
221+ }
222+ p, err := s.memory.alloc(uintptr(n + 1))
223+ if err != nil {
224+ return BufErr
225+ }
226+ copy(unsafe.Slice((*byte)(p), n+1), answer[:n])
227+ array[i].Text = p
228+ lengths[i] = n
229+ return Success
230+ }()
231+ if result != Success {
232+ return result
233+ }
234+ }
235+ *(*unsafe.Pointer)(output) = responses
236+ transferred = true // PAM/module now owns array and response strings via free
237+ return Success
238+}
239+
240+// Schedule every free before executing any, so a panicking allocator seam's
241+// free cannot prevent release of the other known native allocations. The raw
242+// entry catches cleanup panics without exposing their payload.
243+func (s *conversationState) cleanupResponses(responses unsafe.Pointer, lengths []int) {
244+ defer s.memory.free(responses)
245+ for i, response := range unsafe.Slice((*nativeResponse)(responses), len(lengths)) {
246+ if response.Text != nil {
247+ defer func(pointer unsafe.Pointer, size int) {
248+ defer s.memory.free(pointer)
249+ clear(unsafe.Slice((*byte)(pointer), size))
250+ }(response.Text, lengths[i]+1)
251+ }
252+ }
253+}
254+
255+func copyMessage(pointer unsafe.Pointer) ([]byte, bool) {
256+ if pointer == nil {
257+ return nil, false
258+ }
259+ // Read one byte at a time; do not form/read a whole maximum-length slice
260+ // beyond a short string. Caller still must provide readable native memory.
261+ for n := 0; n < MaxMsgSize; n++ {
262+ if *(*byte)(unsafe.Add(pointer, n)) == 0 {
263+ return append([]byte(nil), unsafe.Slice((*byte)(pointer), n)...), true
264+ }
265+ }
266+ return nil, false
267+}
268+
269+func respond(handler Conversation, message Message) (answer []byte, n int, err error) {
270+ answer = make([]byte, MaxRespSize)
271+ defer clear(message.Text)
272+ defer func() {
273+ if recover() != nil {
274+ err = errors.New("pam: conversation handler failed")
275+ }
276+ if err != nil {
277+ clear(answer)
278+ }
279+ }()
280+ n, err = handler.Respond(message, answer)
281+ if err == nil {
282+ if n < 0 || n > len(answer) {
283+ err = ErrInvalidArgument
284+ } else {
285+ for _, b := range answer[:n] {
286+ if b == 0 {
287+ err = ErrInvalidArgument
288+ break
289+ }
290+ }
291+ }
292+ }
293+ return
294+}
@@ -0,0 +1,294 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "errors"
7+ "sync"
8+ "unsafe"
9+
10+ "github.com/bnema/purego"
11+)
12+
13+// Message is a copied Linux-PAM message. Text may be sensitive and is wiped
14+// after Respond returns. Do not retain it; do not log prompts or answers.
15+type Message struct {
16+ Style int32
17+ Text []byte
18+}
19+
20+// Conversation supplies responses without retaining native or Go buffers.
21+// Respond receives a zeroed MaxRespSize-byte answer buffer. Write the answer
22+// there and return its length (0..MaxRespSize), without a NUL. For informational
23+// messages return zero. The library wipes Text and answer even on error/panic.
24+// Do not retain buffers or access them after return, or call Library/Transaction
25+// methods from Respond. Respond must return: no timeout cancels a native call.
26+// Calls for one conversation are serialized, including foreign-thread entry.
27+// Any private secret copies made by the handler remain its responsibility.
28+type Conversation interface {
29+ Respond(message Message, answer []byte) (int, error)
30+}
31+
32+// nativeMemory is the allocator seam for callback failure tests.
33+type nativeMemory interface {
34+ alloc(size uintptr) (unsafe.Pointer, error)
35+ free(pointer unsafe.Pointer)
36+}
37+
38+type conversationState struct {
39+ token uintptr
40+ handler Conversation
41+ memory nativeMemory
42+ serial sync.Mutex
43+ refs int // guarded by conversations.Mutex
44+}
45+
46+var conversations = struct {
47+ sync.Mutex
48+ next uintptr
49+ states map[uintptr]*conversationState
50+}{states: make(map[uintptr]*conversationState)}
51+var conversationChanged = sync.NewCond(&conversations.Mutex)
52+var sharedCallback struct {
53+ sync.Once
54+ address uintptr
55+ err error
56+}
57+
58+// The shared callback is never unreferenced: late calls can safely reject an
59+// unknown cookie without reading any appdata, message or output memory.
60+func callbackAddress() (uintptr, error) {
61+ sharedCallback.Do(func() {
62+ defer func() {
63+ if recover() != nil {
64+ sharedCallback.err = errors.New("pam: callback registration failed")
65+ }
66+ }()
67+ sharedCallback.address = purego.NewCallbackInts(conversationCallback)
68+ })
69+ return sharedCallback.address, sharedCallback.err
70+}
71+
72+func registerConversation(handler Conversation, memory nativeMemory) (*conversationState, error) {
73+ conversations.Lock()
74+ defer conversations.Unlock()
75+ if conversations.next == ^uintptr(0) {
76+ return nil, errors.New("pam: conversation cookie space exhausted")
77+ }
78+ conversations.next++ // never reuse a cookie, including after failed Start
79+ s := &conversationState{token: conversations.next, handler: handler, memory: memory}
80+ conversations.states[s.token] = s
81+ return s, nil
82+}
83+
84+func acquireConversation(token uintptr) *conversationState {
85+ conversations.Lock()
86+ defer conversations.Unlock()
87+ s := conversations.states[token]
88+ if s != nil {
89+ s.refs++
90+ }
91+ return s
92+}
93+
94+func (s *conversationState) leave() {
95+ conversations.Lock()
96+ s.refs--
97+ conversationChanged.Broadcast()
98+ conversations.Unlock()
99+}
100+
101+func (s *conversationState) retire() {
102+ conversations.Lock()
103+ delete(conversations.states, s.token) // close admission before waiting
104+ for s.refs != 0 {
105+ conversationChanged.Wait()
106+ }
107+ conversations.Unlock()
108+}
109+
110+// StartConversation adds the managed Go bridge; the external NativeConversation
111+// API is unchanged. appdata is a numeric opaque void* cookie, NOT a pointer to
112+// memory, and neither PAM nor the bridge may dereference it.
113+func (l *Library) StartConversation(service, user string, handler Conversation) (*Transaction, error) {
114+ if l == nil || l.closed || l.calls == nil || l.memory == nil {
115+ return nil, ErrClosed
116+ }
117+ if handler == nil {
118+ return nil, ErrInvalidArgument
119+ }
120+ address, err := callbackAddress()
121+ if err != nil {
122+ return nil, err
123+ }
124+ s, err := registerConversation(handler, l.memory)
125+ if err != nil {
126+ return nil, err
127+ }
128+ t, err := l.start(service, user, NativeConversation{Function: address}, s)
129+ if err != nil {
130+ s.retire()
131+ }
132+ return t, err
133+}
134+
135+// Raw integer-class entry keeps all native address bits out of Go pointer
136+// values until cookie admission. C int count uses only the low 32 bits.
137+func conversationCallback(args *purego.CallbackArgs) (result uintptr) {
138+ result = uintptr(ConvErr)
139+ defer func() {
140+ if recover() != nil {
141+ result = uintptr(ConvErr)
142+ }
143+ }()
144+ count, cookie := int32(args.Int(0)), args.Int(3)
145+ s := acquireConversation(cookie)
146+ if s == nil {
147+ return result
148+ }
149+ defer s.leave()
150+ messages, output := args.Int(1), args.Int(2)
151+ if count < 1 || count > MaxNumMsg || messages == 0 || output == 0 ||
152+ messages%unsafe.Alignof(uintptr(0)) != 0 || output%unsafe.Alignof(uintptr(0)) != 0 {
153+ return result
154+ }
155+ return uintptr(uint32(s.respondNative(count, admittedNativeAddress(messages), admittedNativeAddress(output))))
156+}
157+
158+// admittedNativeAddress reinterprets native address bits only after admission
159+// and basic validation. This is not an address-validity guarantee; the admitted
160+// native caller must supply readable/writable memory throughout the call.
161+func admittedNativeAddress(bits uintptr) unsafe.Pointer {
162+ return *(*unsafe.Pointer)(unsafe.Pointer(&bits))
163+}
164+
165+func (s *conversationState) respondNative(count int32, messages, output unsafe.Pointer) (status Status) {
166+ status = ConvErr
167+ s.serial.Lock()
168+ defer s.serial.Unlock()
169+ var responses unsafe.Pointer
170+ var lengths []int
171+ transferred := false
172+ defer func() {
173+ if recover() != nil {
174+ status = ConvErr
175+ } // never expose panic payload
176+ if responses != nil && !transferred {
177+ s.cleanupResponses(responses, lengths)
178+ }
179+ }()
180+ if count < 1 || count > MaxNumMsg || messages == nil || output == nil {
181+ return status
182+ }
183+ // Trusted native caller must supply readable arrays and writable output;
184+ // NULL checks and bounded reads cannot validate arbitrary native addresses.
185+ if uintptr(messages)%unsafe.Alignof(uintptr(0)) != 0 || uintptr(output)%unsafe.Alignof(uintptr(0)) != 0 {
186+ return status
187+ }
188+ *(*unsafe.Pointer)(output) = nil
189+ lengths = make([]int, int(count))
190+ var err error
191+ responses, err = s.memory.alloc(uintptr(count) * unsafe.Sizeof(nativeResponse{}))
192+ if err != nil {
193+ return BufErr
194+ }
195+ array := unsafe.Slice((*nativeResponse)(responses), int(count))
196+ pointers := unsafe.Slice((*unsafe.Pointer)(messages), int(count))
197+ for i, pointer := range pointers {
198+ if pointer == nil || uintptr(pointer)%unsafe.Alignof(nativeMessage{}) != 0 {
199+ return ConvErr
200+ }
201+ message := *(*nativeMessage)(pointer)
202+ if message.Style != PromptEchoOff && message.Style != PromptEchoOn && message.Style != ErrorMsg && message.Style != TextInfo {
203+ return ConvErr
204+ }
205+ text, ok := copyMessage(message.Text)
206+ if !ok {
207+ return ConvErr
208+ }
209+ answer, n, err := respond(s.handler, Message{Style: message.Style, Text: text})
210+ if err != nil {
211+ return ConvErr
212+ }
213+ // respond's caller owns wiping the returned answer after native copy.
214+ result := func() Status {
215+ defer clear(answer)
216+ if message.Style == ErrorMsg || message.Style == TextInfo {
217+ if n != 0 {
218+ return ConvErr
219+ }
220+ return Success
221+ }
222+ p, err := s.memory.alloc(uintptr(n + 1))
223+ if err != nil {
224+ return BufErr
225+ }
226+ copy(unsafe.Slice((*byte)(p), n+1), answer[:n])
227+ array[i].Text = p
228+ lengths[i] = n
229+ return Success
230+ }()
231+ if result != Success {
232+ return result
233+ }
234+ }
235+ *(*unsafe.Pointer)(output) = responses
236+ transferred = true // PAM/module now owns array and response strings via free
237+ return Success
238+}
239+
240+// Schedule every free before executing any, so a panicking allocator seam's
241+// free cannot prevent release of the other known native allocations. The raw
242+// entry catches cleanup panics without exposing their payload.
243+func (s *conversationState) cleanupResponses(responses unsafe.Pointer, lengths []int) {
244+ defer s.memory.free(responses)
245+ for i, response := range unsafe.Slice((*nativeResponse)(responses), len(lengths)) {
246+ if response.Text != nil {
247+ defer func(pointer unsafe.Pointer, size int) {
248+ defer s.memory.free(pointer)
249+ clear(unsafe.Slice((*byte)(pointer), size))
250+ }(response.Text, lengths[i]+1)
251+ }
252+ }
253+}
254+
255+func copyMessage(pointer unsafe.Pointer) ([]byte, bool) {
256+ if pointer == nil {
257+ return nil, false
258+ }
259+ // Read one byte at a time; do not form/read a whole maximum-length slice
260+ // beyond a short string. Caller still must provide readable native memory.
261+ for n := 0; n < MaxMsgSize; n++ {
262+ if *(*byte)(unsafe.Add(pointer, n)) == 0 {
263+ return append([]byte(nil), unsafe.Slice((*byte)(pointer), n)...), true
264+ }
265+ }
266+ return nil, false
267+}
268+
269+func respond(handler Conversation, message Message) (answer []byte, n int, err error) {
270+ answer = make([]byte, MaxRespSize)
271+ defer clear(message.Text)
272+ defer func() {
273+ if recover() != nil {
274+ err = errors.New("pam: conversation handler failed")
275+ }
276+ if err != nil {
277+ clear(answer)
278+ }
279+ }()
280+ n, err = handler.Respond(message, answer)
281+ if err == nil {
282+ if n < 0 || n > len(answer) {
283+ err = ErrInvalidArgument
284+ } else {
285+ for _, b := range answer[:n] {
286+ if b == 0 {
287+ err = ErrInvalidArgument
288+ break
289+ }
290+ }
291+ }
292+ }
293+ return
294+}
A conversation_linux_test.go +301 -0
@@ -0,0 +1,301 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
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+
16+func 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+
37+func 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+
45+func 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+
53+func 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+
92+func 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+
130+func 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+
186+func 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+
235+func 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+}
@@ -0,0 +1,301 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
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+
16+func 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+
37+func 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+
45+func 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+
53+func 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+
92+func 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+
130+func 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+
186+func 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+
235+func 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+}
A conversation_mock_test.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package pam
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockConversation creates a new instance of MockConversation. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockConversation(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockConversation {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockConversation{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockConversation is an autogenerated mock type for the Conversation type
35+type MockConversation struct {
36+ mock.Mock
37+}
38+
39+type MockConversation_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockConversation) EXPECT() *MockConversation_Expecter {
44+ return &MockConversation_Expecter{mock: &_m.Mock}
45+}
46+
47+// Respond provides a mock function for the type MockConversation
48+func (_mock *MockConversation) Respond(message Message, answer []byte) (int, error) {
49+ ret := _mock.Called(message, answer)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Respond")
53+ }
54+
55+ var r0 int
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func(Message, []byte) (int, error)); ok {
58+ return returnFunc(message, answer)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func(Message, []byte) int); ok {
61+ r0 = returnFunc(message, answer)
62+ } else {
63+ r0 = ret.Get(0).(int)
64+ }
65+ if returnFunc, ok := ret.Get(1).(func(Message, []byte) error); ok {
66+ r1 = returnFunc(message, answer)
67+ } else {
68+ r1 = ret.Error(1)
69+ }
70+ return r0, r1
71+}
72+
73+// MockConversation_Respond_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Respond'
74+type MockConversation_Respond_Call struct {
75+ *mock.Call
76+}
77+
78+// Respond is a helper method to define mock.On call
79+// - message Message
80+// - answer []byte
81+func (_e *MockConversation_Expecter) Respond(message any, answer any) *MockConversation_Respond_Call {
82+ return &MockConversation_Respond_Call{Call: _e.mock.On("Respond", message, answer)}
83+}
84+
85+func (_c *MockConversation_Respond_Call) Run(run func(message Message, answer []byte)) *MockConversation_Respond_Call {
86+ _c.Call.Run(func(args mock.Arguments) {
87+ var arg0 Message
88+ if args[0] != nil {
89+ arg0 = args[0].(Message)
90+ }
91+ var arg1 []byte
92+ if args[1] != nil {
93+ arg1 = args[1].([]byte)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MockConversation_Respond_Call) Return(n int, err error) *MockConversation_Respond_Call {
104+ _c.Call.Return(n, err)
105+ return _c
106+}
107+
108+func (_c *MockConversation_Respond_Call) RunAndReturn(run func(message Message, answer []byte) (int, error)) *MockConversation_Respond_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package pam
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockConversation creates a new instance of MockConversation. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockConversation(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockConversation {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &MockConversation{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// MockConversation is an autogenerated mock type for the Conversation type
35+type MockConversation struct {
36+ mock.Mock
37+}
38+
39+type MockConversation_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *MockConversation) EXPECT() *MockConversation_Expecter {
44+ return &MockConversation_Expecter{mock: &_m.Mock}
45+}
46+
47+// Respond provides a mock function for the type MockConversation
48+func (_mock *MockConversation) Respond(message Message, answer []byte) (int, error) {
49+ ret := _mock.Called(message, answer)
50+
51+ if len(ret) == 0 {
52+ panic("no return value specified for Respond")
53+ }
54+
55+ var r0 int
56+ var r1 error
57+ if returnFunc, ok := ret.Get(0).(func(Message, []byte) (int, error)); ok {
58+ return returnFunc(message, answer)
59+ }
60+ if returnFunc, ok := ret.Get(0).(func(Message, []byte) int); ok {
61+ r0 = returnFunc(message, answer)
62+ } else {
63+ r0 = ret.Get(0).(int)
64+ }
65+ if returnFunc, ok := ret.Get(1).(func(Message, []byte) error); ok {
66+ r1 = returnFunc(message, answer)
67+ } else {
68+ r1 = ret.Error(1)
69+ }
70+ return r0, r1
71+}
72+
73+// MockConversation_Respond_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Respond'
74+type MockConversation_Respond_Call struct {
75+ *mock.Call
76+}
77+
78+// Respond is a helper method to define mock.On call
79+// - message Message
80+// - answer []byte
81+func (_e *MockConversation_Expecter) Respond(message any, answer any) *MockConversation_Respond_Call {
82+ return &MockConversation_Respond_Call{Call: _e.mock.On("Respond", message, answer)}
83+}
84+
85+func (_c *MockConversation_Respond_Call) Run(run func(message Message, answer []byte)) *MockConversation_Respond_Call {
86+ _c.Call.Run(func(args mock.Arguments) {
87+ var arg0 Message
88+ if args[0] != nil {
89+ arg0 = args[0].(Message)
90+ }
91+ var arg1 []byte
92+ if args[1] != nil {
93+ arg1 = args[1].([]byte)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MockConversation_Respond_Call) Return(n int, err error) *MockConversation_Respond_Call {
104+ _c.Call.Return(n, err)
105+ return _c
106+}
107+
108+func (_c *MockConversation_Respond_Call) RunAndReturn(run func(message Message, answer []byte) (int, error)) *MockConversation_Respond_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A conversation_review_linux_test.go +113 -0
@@ -0,0 +1,113 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "testing"
7+ "unsafe"
8+
9+ "github.com/bnema/purego"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestRawCallbackRejectsUnknownAndRetiredArbitraryBits(t *testing.T) {
15+ l, _, _ := testLibrary(t)
16+ s := callbackState(t, NewMockConversation(t), l.memory)
17+ s.retire()
18+ address, err := callbackAddress()
19+ require.NoError(t, err)
20+ for _, cookie := range []uintptr{0, s.token, ^uintptr(0)} {
21+ for _, count := range []uintptr{1, 0, ^uintptr(0), uintptr(1)<<32 | 1} {
22+ for _, bits := range []uintptr{0, 1, 8, ^uintptr(0), uintptr(1) << 63} {
23+ // All values stay integers throughout unknown-cookie admission.
24+ // Both aligned and noncanonical addresses must be ignored.
25+ result, _, _ := purego.SyscallN(address, count, bits, ^bits, cookie)
26+ require.Equal(t, uintptr(ConvErr), result)
27+ }
28+ }
29+ }
30+}
31+
32+func TestCookieExhaustionNeverWrapsOrReuses(t *testing.T) {
33+ l, _, _ := testLibrary(t)
34+ handler := NewMockConversation(t)
35+ conversations.Lock()
36+ require.Empty(t, conversations.states)
37+ previous := conversations.next
38+ conversations.next = ^uintptr(0) - 1
39+ conversations.Unlock()
40+ // Test-only counter isolation; production never resets the counter.
41+ t.Cleanup(func() {
42+ conversations.Lock()
43+ defer conversations.Unlock()
44+ require.Empty(t, conversations.states)
45+ conversations.next = previous
46+ })
47+ last, err := registerConversation(handler, l.memory)
48+ require.NoError(t, err)
49+ require.Equal(t, ^uintptr(0), last.token)
50+ last.retire()
51+ for range 2 {
52+ s, err := registerConversation(handler, l.memory)
53+ require.Error(t, err)
54+ require.Nil(t, s)
55+ conversations.Lock()
56+ require.Equal(t, ^uintptr(0), conversations.next)
57+ require.Empty(t, conversations.states)
58+ conversations.Unlock()
59+ }
60+}
61+
62+func TestAllocatorPanicsStillWipeAndReleasePartialResponses(t *testing.T) {
63+ for _, panicFree := range []bool{false, true} {
64+ t.Run(map[bool]string{false: "alloc-panic", true: "alloc-and-free-panic"}[panicFree], func(t *testing.T) {
65+ l, _, _ := testLibrary(t)
66+ memory := newMocknativeMemory(t)
67+ handler := NewMockConversation(t)
68+ s := callbackState(t, handler, memory)
69+ messages, output := nativeMessages(t, l.memory, PromptEchoOff, PromptEchoOn)
70+ allocated := make(map[unsafe.Pointer]uintptr)
71+ index := 0
72+ memory.EXPECT().alloc(mock.Anything).RunAndReturn(func(size uintptr) (unsafe.Pointer, error) {
73+ index++
74+ if index == 3 {
75+ panic("allocator payload must not escape")
76+ }
77+ p, err := l.memory.alloc(size)
78+ allocated[p] = size
79+ return p, err
80+ }).Times(3)
81+ memory.EXPECT().free(mock.Anything).Run(func(p unsafe.Pointer) {
82+ size, ok := allocated[p]
83+ require.True(t, ok)
84+ if size == 4 {
85+ require.Equal(t, make([]byte, 4), unsafe.Slice((*byte)(p), 4))
86+ }
87+ delete(allocated, p)
88+ l.memory.free(p)
89+ if panicFree && size == 4 {
90+ panic("free payload must not escape")
91+ }
92+ }).Times(2)
93+ var answers, prompts [][]byte
94+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(message Message, answer []byte) (int, error) {
95+ answers = append(answers, answer)
96+ prompts = append(prompts, message.Text)
97+ copy(answer, "abc")
98+ return 3, nil
99+ }).Times(2)
100+ require.Equal(t, ConvErr, invokeConversation(t, s.token, 2, messages, output))
101+ require.Nil(t, *(*unsafe.Pointer)(output))
102+ require.Empty(t, allocated)
103+ for _, bytes := range append(answers, prompts...) {
104+ require.Equal(t, make([]byte, len(bytes)), bytes)
105+ }
106+ // Reference release also survives allocator and cleanup panics.
107+ conversations.Lock()
108+ require.Zero(t, s.refs)
109+ conversations.Unlock()
110+ s.retire()
111+ })
112+ }
113+}
@@ -0,0 +1,113 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "testing"
7+ "unsafe"
8+
9+ "github.com/bnema/purego"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestRawCallbackRejectsUnknownAndRetiredArbitraryBits(t *testing.T) {
15+ l, _, _ := testLibrary(t)
16+ s := callbackState(t, NewMockConversation(t), l.memory)
17+ s.retire()
18+ address, err := callbackAddress()
19+ require.NoError(t, err)
20+ for _, cookie := range []uintptr{0, s.token, ^uintptr(0)} {
21+ for _, count := range []uintptr{1, 0, ^uintptr(0), uintptr(1)<<32 | 1} {
22+ for _, bits := range []uintptr{0, 1, 8, ^uintptr(0), uintptr(1) << 63} {
23+ // All values stay integers throughout unknown-cookie admission.
24+ // Both aligned and noncanonical addresses must be ignored.
25+ result, _, _ := purego.SyscallN(address, count, bits, ^bits, cookie)
26+ require.Equal(t, uintptr(ConvErr), result)
27+ }
28+ }
29+ }
30+}
31+
32+func TestCookieExhaustionNeverWrapsOrReuses(t *testing.T) {
33+ l, _, _ := testLibrary(t)
34+ handler := NewMockConversation(t)
35+ conversations.Lock()
36+ require.Empty(t, conversations.states)
37+ previous := conversations.next
38+ conversations.next = ^uintptr(0) - 1
39+ conversations.Unlock()
40+ // Test-only counter isolation; production never resets the counter.
41+ t.Cleanup(func() {
42+ conversations.Lock()
43+ defer conversations.Unlock()
44+ require.Empty(t, conversations.states)
45+ conversations.next = previous
46+ })
47+ last, err := registerConversation(handler, l.memory)
48+ require.NoError(t, err)
49+ require.Equal(t, ^uintptr(0), last.token)
50+ last.retire()
51+ for range 2 {
52+ s, err := registerConversation(handler, l.memory)
53+ require.Error(t, err)
54+ require.Nil(t, s)
55+ conversations.Lock()
56+ require.Equal(t, ^uintptr(0), conversations.next)
57+ require.Empty(t, conversations.states)
58+ conversations.Unlock()
59+ }
60+}
61+
62+func TestAllocatorPanicsStillWipeAndReleasePartialResponses(t *testing.T) {
63+ for _, panicFree := range []bool{false, true} {
64+ t.Run(map[bool]string{false: "alloc-panic", true: "alloc-and-free-panic"}[panicFree], func(t *testing.T) {
65+ l, _, _ := testLibrary(t)
66+ memory := newMocknativeMemory(t)
67+ handler := NewMockConversation(t)
68+ s := callbackState(t, handler, memory)
69+ messages, output := nativeMessages(t, l.memory, PromptEchoOff, PromptEchoOn)
70+ allocated := make(map[unsafe.Pointer]uintptr)
71+ index := 0
72+ memory.EXPECT().alloc(mock.Anything).RunAndReturn(func(size uintptr) (unsafe.Pointer, error) {
73+ index++
74+ if index == 3 {
75+ panic("allocator payload must not escape")
76+ }
77+ p, err := l.memory.alloc(size)
78+ allocated[p] = size
79+ return p, err
80+ }).Times(3)
81+ memory.EXPECT().free(mock.Anything).Run(func(p unsafe.Pointer) {
82+ size, ok := allocated[p]
83+ require.True(t, ok)
84+ if size == 4 {
85+ require.Equal(t, make([]byte, 4), unsafe.Slice((*byte)(p), 4))
86+ }
87+ delete(allocated, p)
88+ l.memory.free(p)
89+ if panicFree && size == 4 {
90+ panic("free payload must not escape")
91+ }
92+ }).Times(2)
93+ var answers, prompts [][]byte
94+ handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(message Message, answer []byte) (int, error) {
95+ answers = append(answers, answer)
96+ prompts = append(prompts, message.Text)
97+ copy(answer, "abc")
98+ return 3, nil
99+ }).Times(2)
100+ require.Equal(t, ConvErr, invokeConversation(t, s.token, 2, messages, output))
101+ require.Nil(t, *(*unsafe.Pointer)(output))
102+ require.Empty(t, allocated)
103+ for _, bytes := range append(answers, prompts...) {
104+ require.Equal(t, make([]byte, len(bytes)), bytes)
105+ }
106+ // Reference release also survives allocator and cleanup panics.
107+ conversations.Lock()
108+ require.Zero(t, s.refs)
109+ conversations.Unlock()
110+ s.retire()
111+ })
112+ }
113+}
A go.mod +13 -0
@@ -0,0 +1,13 @@
1+module github.com/bnema/purego-pam
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/purego v0.13.0-bnema.1
7+ github.com/stretchr/testify v1.12.1
8+)
9+
10+require (
11+ github.com/stretchr/objx v0.5.3 // indirect
12+ go.yaml.in/yaml/v3 v3.0.5 // indirect
13+)
@@ -0,0 +1,13 @@
1+module github.com/bnema/purego-pam
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/purego v0.13.0-bnema.1
7+ github.com/stretchr/testify v1.12.1
8+)
9+
10+require (
11+ github.com/stretchr/objx v0.5.3 // indirect
12+ go.yaml.in/yaml/v3 v3.0.5 // indirect
13+)
A go.sum +8 -0 generated file: collapsed
A library_functions_mock_test.go +287 -0
@@ -0,0 +1,287 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package pam
6+
7+import (
8+ "unsafe"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocklibraryFunctions creates a new instance of mocklibraryFunctions. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocklibraryFunctions(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocklibraryFunctions {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocklibraryFunctions{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocklibraryFunctions is an autogenerated mock type for the libraryFunctions type
37+type mocklibraryFunctions struct {
38+ mock.Mock
39+}
40+
41+type mocklibraryFunctions_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocklibraryFunctions) EXPECT() *mocklibraryFunctions_Expecter {
46+ return &mocklibraryFunctions_Expecter{mock: &_m.Mock}
47+}
48+
49+// AcctMgmt provides a mock function for the type mocklibraryFunctions
50+func (_mock *mocklibraryFunctions) AcctMgmt(handle unsafe.Pointer, flags Flags) Status {
51+ ret := _mock.Called(handle, flags)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for AcctMgmt")
55+ }
56+
57+ var r0 Status
58+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, Flags) Status); ok {
59+ r0 = returnFunc(handle, flags)
60+ } else {
61+ r0 = ret.Get(0).(Status)
62+ }
63+ return r0
64+}
65+
66+// mocklibraryFunctions_AcctMgmt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AcctMgmt'
67+type mocklibraryFunctions_AcctMgmt_Call struct {
68+ *mock.Call
69+}
70+
71+// AcctMgmt is a helper method to define mock.On call
72+// - handle unsafe.Pointer
73+// - flags Flags
74+func (_e *mocklibraryFunctions_Expecter) AcctMgmt(handle any, flags any) *mocklibraryFunctions_AcctMgmt_Call {
75+ return &mocklibraryFunctions_AcctMgmt_Call{Call: _e.mock.On("AcctMgmt", handle, flags)}
76+}
77+
78+func (_c *mocklibraryFunctions_AcctMgmt_Call) Run(run func(handle unsafe.Pointer, flags Flags)) *mocklibraryFunctions_AcctMgmt_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 unsafe.Pointer
81+ if args[0] != nil {
82+ arg0 = args[0].(unsafe.Pointer)
83+ }
84+ var arg1 Flags
85+ if args[1] != nil {
86+ arg1 = args[1].(Flags)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ )
92+ })
93+ return _c
94+}
95+
96+func (_c *mocklibraryFunctions_AcctMgmt_Call) Return(status Status) *mocklibraryFunctions_AcctMgmt_Call {
97+ _c.Call.Return(status)
98+ return _c
99+}
100+
101+func (_c *mocklibraryFunctions_AcctMgmt_Call) RunAndReturn(run func(handle unsafe.Pointer, flags Flags) Status) *mocklibraryFunctions_AcctMgmt_Call {
102+ _c.Call.Return(run)
103+ return _c
104+}
105+
106+// Authenticate provides a mock function for the type mocklibraryFunctions
107+func (_mock *mocklibraryFunctions) Authenticate(handle unsafe.Pointer, flags Flags) Status {
108+ ret := _mock.Called(handle, flags)
109+
110+ if len(ret) == 0 {
111+ panic("no return value specified for Authenticate")
112+ }
113+
114+ var r0 Status
115+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, Flags) Status); ok {
116+ r0 = returnFunc(handle, flags)
117+ } else {
118+ r0 = ret.Get(0).(Status)
119+ }
120+ return r0
121+}
122+
123+// mocklibraryFunctions_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
124+type mocklibraryFunctions_Authenticate_Call struct {
125+ *mock.Call
126+}
127+
128+// Authenticate is a helper method to define mock.On call
129+// - handle unsafe.Pointer
130+// - flags Flags
131+func (_e *mocklibraryFunctions_Expecter) Authenticate(handle any, flags any) *mocklibraryFunctions_Authenticate_Call {
132+ return &mocklibraryFunctions_Authenticate_Call{Call: _e.mock.On("Authenticate", handle, flags)}
133+}
134+
135+func (_c *mocklibraryFunctions_Authenticate_Call) Run(run func(handle unsafe.Pointer, flags Flags)) *mocklibraryFunctions_Authenticate_Call {
136+ _c.Call.Run(func(args mock.Arguments) {
137+ var arg0 unsafe.Pointer
138+ if args[0] != nil {
139+ arg0 = args[0].(unsafe.Pointer)
140+ }
141+ var arg1 Flags
142+ if args[1] != nil {
143+ arg1 = args[1].(Flags)
144+ }
145+ run(
146+ arg0,
147+ arg1,
148+ )
149+ })
150+ return _c
151+}
152+
153+func (_c *mocklibraryFunctions_Authenticate_Call) Return(status Status) *mocklibraryFunctions_Authenticate_Call {
154+ _c.Call.Return(status)
155+ return _c
156+}
157+
158+func (_c *mocklibraryFunctions_Authenticate_Call) RunAndReturn(run func(handle unsafe.Pointer, flags Flags) Status) *mocklibraryFunctions_Authenticate_Call {
159+ _c.Call.Return(run)
160+ return _c
161+}
162+
163+// End provides a mock function for the type mocklibraryFunctions
164+func (_mock *mocklibraryFunctions) End(handle unsafe.Pointer, status Status) Status {
165+ ret := _mock.Called(handle, status)
166+
167+ if len(ret) == 0 {
168+ panic("no return value specified for End")
169+ }
170+
171+ var r0 Status
172+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, Status) Status); ok {
173+ r0 = returnFunc(handle, status)
174+ } else {
175+ r0 = ret.Get(0).(Status)
176+ }
177+ return r0
178+}
179+
180+// mocklibraryFunctions_End_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'End'
181+type mocklibraryFunctions_End_Call struct {
182+ *mock.Call
183+}
184+
185+// End is a helper method to define mock.On call
186+// - handle unsafe.Pointer
187+// - status Status
188+func (_e *mocklibraryFunctions_Expecter) End(handle any, status any) *mocklibraryFunctions_End_Call {
189+ return &mocklibraryFunctions_End_Call{Call: _e.mock.On("End", handle, status)}
190+}
191+
192+func (_c *mocklibraryFunctions_End_Call) Run(run func(handle unsafe.Pointer, status Status)) *mocklibraryFunctions_End_Call {
193+ _c.Call.Run(func(args mock.Arguments) {
194+ var arg0 unsafe.Pointer
195+ if args[0] != nil {
196+ arg0 = args[0].(unsafe.Pointer)
197+ }
198+ var arg1 Status
199+ if args[1] != nil {
200+ arg1 = args[1].(Status)
201+ }
202+ run(
203+ arg0,
204+ arg1,
205+ )
206+ })
207+ return _c
208+}
209+
210+func (_c *mocklibraryFunctions_End_Call) Return(status1 Status) *mocklibraryFunctions_End_Call {
211+ _c.Call.Return(status1)
212+ return _c
213+}
214+
215+func (_c *mocklibraryFunctions_End_Call) RunAndReturn(run func(handle unsafe.Pointer, status Status) Status) *mocklibraryFunctions_End_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
220+// Start provides a mock function for the type mocklibraryFunctions
221+func (_mock *mocklibraryFunctions) Start(service unsafe.Pointer, user unsafe.Pointer, conversation unsafe.Pointer, output unsafe.Pointer) Status {
222+ ret := _mock.Called(service, user, conversation, output)
223+
224+ if len(ret) == 0 {
225+ panic("no return value specified for Start")
226+ }
227+
228+ var r0 Status
229+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, unsafe.Pointer, unsafe.Pointer, unsafe.Pointer) Status); ok {
230+ r0 = returnFunc(service, user, conversation, output)
231+ } else {
232+ r0 = ret.Get(0).(Status)
233+ }
234+ return r0
235+}
236+
237+// mocklibraryFunctions_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
238+type mocklibraryFunctions_Start_Call struct {
239+ *mock.Call
240+}
241+
242+// Start is a helper method to define mock.On call
243+// - service unsafe.Pointer
244+// - user unsafe.Pointer
245+// - conversation unsafe.Pointer
246+// - output unsafe.Pointer
247+func (_e *mocklibraryFunctions_Expecter) Start(service any, user any, conversation any, output any) *mocklibraryFunctions_Start_Call {
248+ return &mocklibraryFunctions_Start_Call{Call: _e.mock.On("Start", service, user, conversation, output)}
249+}
250+
251+func (_c *mocklibraryFunctions_Start_Call) Run(run func(service unsafe.Pointer, user unsafe.Pointer, conversation unsafe.Pointer, output unsafe.Pointer)) *mocklibraryFunctions_Start_Call {
252+ _c.Call.Run(func(args mock.Arguments) {
253+ var arg0 unsafe.Pointer
254+ if args[0] != nil {
255+ arg0 = args[0].(unsafe.Pointer)
256+ }
257+ var arg1 unsafe.Pointer
258+ if args[1] != nil {
259+ arg1 = args[1].(unsafe.Pointer)
260+ }
261+ var arg2 unsafe.Pointer
262+ if args[2] != nil {
263+ arg2 = args[2].(unsafe.Pointer)
264+ }
265+ var arg3 unsafe.Pointer
266+ if args[3] != nil {
267+ arg3 = args[3].(unsafe.Pointer)
268+ }
269+ run(
270+ arg0,
271+ arg1,
272+ arg2,
273+ arg3,
274+ )
275+ })
276+ return _c
277+}
278+
279+func (_c *mocklibraryFunctions_Start_Call) Return(status Status) *mocklibraryFunctions_Start_Call {
280+ _c.Call.Return(status)
281+ return _c
282+}
283+
284+func (_c *mocklibraryFunctions_Start_Call) RunAndReturn(run func(service unsafe.Pointer, user unsafe.Pointer, conversation unsafe.Pointer, output unsafe.Pointer) Status) *mocklibraryFunctions_Start_Call {
285+ _c.Call.Return(run)
286+ return _c
287+}
@@ -0,0 +1,287 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package pam
6+
7+import (
8+ "unsafe"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocklibraryFunctions creates a new instance of mocklibraryFunctions. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocklibraryFunctions(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocklibraryFunctions {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocklibraryFunctions{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocklibraryFunctions is an autogenerated mock type for the libraryFunctions type
37+type mocklibraryFunctions struct {
38+ mock.Mock
39+}
40+
41+type mocklibraryFunctions_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocklibraryFunctions) EXPECT() *mocklibraryFunctions_Expecter {
46+ return &mocklibraryFunctions_Expecter{mock: &_m.Mock}
47+}
48+
49+// AcctMgmt provides a mock function for the type mocklibraryFunctions
50+func (_mock *mocklibraryFunctions) AcctMgmt(handle unsafe.Pointer, flags Flags) Status {
51+ ret := _mock.Called(handle, flags)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for AcctMgmt")
55+ }
56+
57+ var r0 Status
58+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, Flags) Status); ok {
59+ r0 = returnFunc(handle, flags)
60+ } else {
61+ r0 = ret.Get(0).(Status)
62+ }
63+ return r0
64+}
65+
66+// mocklibraryFunctions_AcctMgmt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AcctMgmt'
67+type mocklibraryFunctions_AcctMgmt_Call struct {
68+ *mock.Call
69+}
70+
71+// AcctMgmt is a helper method to define mock.On call
72+// - handle unsafe.Pointer
73+// - flags Flags
74+func (_e *mocklibraryFunctions_Expecter) AcctMgmt(handle any, flags any) *mocklibraryFunctions_AcctMgmt_Call {
75+ return &mocklibraryFunctions_AcctMgmt_Call{Call: _e.mock.On("AcctMgmt", handle, flags)}
76+}
77+
78+func (_c *mocklibraryFunctions_AcctMgmt_Call) Run(run func(handle unsafe.Pointer, flags Flags)) *mocklibraryFunctions_AcctMgmt_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 unsafe.Pointer
81+ if args[0] != nil {
82+ arg0 = args[0].(unsafe.Pointer)
83+ }
84+ var arg1 Flags
85+ if args[1] != nil {
86+ arg1 = args[1].(Flags)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ )
92+ })
93+ return _c
94+}
95+
96+func (_c *mocklibraryFunctions_AcctMgmt_Call) Return(status Status) *mocklibraryFunctions_AcctMgmt_Call {
97+ _c.Call.Return(status)
98+ return _c
99+}
100+
101+func (_c *mocklibraryFunctions_AcctMgmt_Call) RunAndReturn(run func(handle unsafe.Pointer, flags Flags) Status) *mocklibraryFunctions_AcctMgmt_Call {
102+ _c.Call.Return(run)
103+ return _c
104+}
105+
106+// Authenticate provides a mock function for the type mocklibraryFunctions
107+func (_mock *mocklibraryFunctions) Authenticate(handle unsafe.Pointer, flags Flags) Status {
108+ ret := _mock.Called(handle, flags)
109+
110+ if len(ret) == 0 {
111+ panic("no return value specified for Authenticate")
112+ }
113+
114+ var r0 Status
115+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, Flags) Status); ok {
116+ r0 = returnFunc(handle, flags)
117+ } else {
118+ r0 = ret.Get(0).(Status)
119+ }
120+ return r0
121+}
122+
123+// mocklibraryFunctions_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate'
124+type mocklibraryFunctions_Authenticate_Call struct {
125+ *mock.Call
126+}
127+
128+// Authenticate is a helper method to define mock.On call
129+// - handle unsafe.Pointer
130+// - flags Flags
131+func (_e *mocklibraryFunctions_Expecter) Authenticate(handle any, flags any) *mocklibraryFunctions_Authenticate_Call {
132+ return &mocklibraryFunctions_Authenticate_Call{Call: _e.mock.On("Authenticate", handle, flags)}
133+}
134+
135+func (_c *mocklibraryFunctions_Authenticate_Call) Run(run func(handle unsafe.Pointer, flags Flags)) *mocklibraryFunctions_Authenticate_Call {
136+ _c.Call.Run(func(args mock.Arguments) {
137+ var arg0 unsafe.Pointer
138+ if args[0] != nil {
139+ arg0 = args[0].(unsafe.Pointer)
140+ }
141+ var arg1 Flags
142+ if args[1] != nil {
143+ arg1 = args[1].(Flags)
144+ }
145+ run(
146+ arg0,
147+ arg1,
148+ )
149+ })
150+ return _c
151+}
152+
153+func (_c *mocklibraryFunctions_Authenticate_Call) Return(status Status) *mocklibraryFunctions_Authenticate_Call {
154+ _c.Call.Return(status)
155+ return _c
156+}
157+
158+func (_c *mocklibraryFunctions_Authenticate_Call) RunAndReturn(run func(handle unsafe.Pointer, flags Flags) Status) *mocklibraryFunctions_Authenticate_Call {
159+ _c.Call.Return(run)
160+ return _c
161+}
162+
163+// End provides a mock function for the type mocklibraryFunctions
164+func (_mock *mocklibraryFunctions) End(handle unsafe.Pointer, status Status) Status {
165+ ret := _mock.Called(handle, status)
166+
167+ if len(ret) == 0 {
168+ panic("no return value specified for End")
169+ }
170+
171+ var r0 Status
172+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, Status) Status); ok {
173+ r0 = returnFunc(handle, status)
174+ } else {
175+ r0 = ret.Get(0).(Status)
176+ }
177+ return r0
178+}
179+
180+// mocklibraryFunctions_End_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'End'
181+type mocklibraryFunctions_End_Call struct {
182+ *mock.Call
183+}
184+
185+// End is a helper method to define mock.On call
186+// - handle unsafe.Pointer
187+// - status Status
188+func (_e *mocklibraryFunctions_Expecter) End(handle any, status any) *mocklibraryFunctions_End_Call {
189+ return &mocklibraryFunctions_End_Call{Call: _e.mock.On("End", handle, status)}
190+}
191+
192+func (_c *mocklibraryFunctions_End_Call) Run(run func(handle unsafe.Pointer, status Status)) *mocklibraryFunctions_End_Call {
193+ _c.Call.Run(func(args mock.Arguments) {
194+ var arg0 unsafe.Pointer
195+ if args[0] != nil {
196+ arg0 = args[0].(unsafe.Pointer)
197+ }
198+ var arg1 Status
199+ if args[1] != nil {
200+ arg1 = args[1].(Status)
201+ }
202+ run(
203+ arg0,
204+ arg1,
205+ )
206+ })
207+ return _c
208+}
209+
210+func (_c *mocklibraryFunctions_End_Call) Return(status1 Status) *mocklibraryFunctions_End_Call {
211+ _c.Call.Return(status1)
212+ return _c
213+}
214+
215+func (_c *mocklibraryFunctions_End_Call) RunAndReturn(run func(handle unsafe.Pointer, status Status) Status) *mocklibraryFunctions_End_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
220+// Start provides a mock function for the type mocklibraryFunctions
221+func (_mock *mocklibraryFunctions) Start(service unsafe.Pointer, user unsafe.Pointer, conversation unsafe.Pointer, output unsafe.Pointer) Status {
222+ ret := _mock.Called(service, user, conversation, output)
223+
224+ if len(ret) == 0 {
225+ panic("no return value specified for Start")
226+ }
227+
228+ var r0 Status
229+ if returnFunc, ok := ret.Get(0).(func(unsafe.Pointer, unsafe.Pointer, unsafe.Pointer, unsafe.Pointer) Status); ok {
230+ r0 = returnFunc(service, user, conversation, output)
231+ } else {
232+ r0 = ret.Get(0).(Status)
233+ }
234+ return r0
235+}
236+
237+// mocklibraryFunctions_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
238+type mocklibraryFunctions_Start_Call struct {
239+ *mock.Call
240+}
241+
242+// Start is a helper method to define mock.On call
243+// - service unsafe.Pointer
244+// - user unsafe.Pointer
245+// - conversation unsafe.Pointer
246+// - output unsafe.Pointer
247+func (_e *mocklibraryFunctions_Expecter) Start(service any, user any, conversation any, output any) *mocklibraryFunctions_Start_Call {
248+ return &mocklibraryFunctions_Start_Call{Call: _e.mock.On("Start", service, user, conversation, output)}
249+}
250+
251+func (_c *mocklibraryFunctions_Start_Call) Run(run func(service unsafe.Pointer, user unsafe.Pointer, conversation unsafe.Pointer, output unsafe.Pointer)) *mocklibraryFunctions_Start_Call {
252+ _c.Call.Run(func(args mock.Arguments) {
253+ var arg0 unsafe.Pointer
254+ if args[0] != nil {
255+ arg0 = args[0].(unsafe.Pointer)
256+ }
257+ var arg1 unsafe.Pointer
258+ if args[1] != nil {
259+ arg1 = args[1].(unsafe.Pointer)
260+ }
261+ var arg2 unsafe.Pointer
262+ if args[2] != nil {
263+ arg2 = args[2].(unsafe.Pointer)
264+ }
265+ var arg3 unsafe.Pointer
266+ if args[3] != nil {
267+ arg3 = args[3].(unsafe.Pointer)
268+ }
269+ run(
270+ arg0,
271+ arg1,
272+ arg2,
273+ arg3,
274+ )
275+ })
276+ return _c
277+}
278+
279+func (_c *mocklibraryFunctions_Start_Call) Return(status Status) *mocklibraryFunctions_Start_Call {
280+ _c.Call.Return(status)
281+ return _c
282+}
283+
284+func (_c *mocklibraryFunctions_Start_Call) RunAndReturn(run func(service unsafe.Pointer, user unsafe.Pointer, conversation unsafe.Pointer, output unsafe.Pointer) Status) *mocklibraryFunctions_Start_Call {
285+ _c.Call.Return(run)
286+ return _c
287+}
A memory_linux.go +41 -0
@@ -0,0 +1,41 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "errors"
7+ "strings"
8+ "unsafe"
9+)
10+
11+var ErrAllocation = errors.New("pam: native allocation failed")
12+
13+type nativeAllocator struct {
14+ malloc func(uintptr) unsafe.Pointer
15+ freeFn func(unsafe.Pointer)
16+}
17+
18+// alloc returns zero-filled malloc-owned memory; free must come from the same
19+// libc. Returning unsafe.Pointer directly avoids uintptr-to-pointer arithmetic.
20+func (a *nativeAllocator) alloc(size uintptr) (unsafe.Pointer, error) {
21+ p := a.malloc(size)
22+ if p == nil {
23+ return nil, ErrAllocation
24+ }
25+ clear(unsafe.Slice((*byte)(p), int(size)))
26+ return p, nil
27+}
28+
29+func (a *nativeAllocator) free(p unsafe.Pointer) { a.freeFn(p) }
30+
31+func nativeCString(a nativeMemory, text string) (unsafe.Pointer, error) {
32+ if strings.ContainsRune(text, '\x00') {
33+ return nil, ErrInvalidArgument
34+ }
35+ p, err := a.alloc(uintptr(len(text)) + 1)
36+ if err != nil {
37+ return nil, err
38+ }
39+ copy(unsafe.Slice((*byte)(p), len(text)+1), text)
40+ return p, nil
41+}
@@ -0,0 +1,41 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "errors"
7+ "strings"
8+ "unsafe"
9+)
10+
11+var ErrAllocation = errors.New("pam: native allocation failed")
12+
13+type nativeAllocator struct {
14+ malloc func(uintptr) unsafe.Pointer
15+ freeFn func(unsafe.Pointer)
16+}
17+
18+// alloc returns zero-filled malloc-owned memory; free must come from the same
19+// libc. Returning unsafe.Pointer directly avoids uintptr-to-pointer arithmetic.
20+func (a *nativeAllocator) alloc(size uintptr) (unsafe.Pointer, error) {
21+ p := a.malloc(size)
22+ if p == nil {
23+ return nil, ErrAllocation
24+ }
25+ clear(unsafe.Slice((*byte)(p), int(size)))
26+ return p, nil
27+}
28+
29+func (a *nativeAllocator) free(p unsafe.Pointer) { a.freeFn(p) }
30+
31+func nativeCString(a nativeMemory, text string) (unsafe.Pointer, error) {
32+ if strings.ContainsRune(text, '\x00') {
33+ return nil, ErrInvalidArgument
34+ }
35+ p, err := a.alloc(uintptr(len(text)) + 1)
36+ if err != nil {
37+ return nil, err
38+ }
39+ copy(unsafe.Slice((*byte)(p), len(text)+1), text)
40+ return p, nil
41+}
A native_memory_mock_test.go +147 -0
@@ -0,0 +1,147 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package pam
6+
7+import (
8+ "unsafe"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocknativeMemory creates a new instance of mocknativeMemory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocknativeMemory(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocknativeMemory {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocknativeMemory{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocknativeMemory is an autogenerated mock type for the nativeMemory type
37+type mocknativeMemory struct {
38+ mock.Mock
39+}
40+
41+type mocknativeMemory_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocknativeMemory) EXPECT() *mocknativeMemory_Expecter {
46+ return &mocknativeMemory_Expecter{mock: &_m.Mock}
47+}
48+
49+// alloc provides a mock function for the type mocknativeMemory
50+func (_mock *mocknativeMemory) alloc(size uintptr) (unsafe.Pointer, error) {
51+ ret := _mock.Called(size)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for alloc")
55+ }
56+
57+ var r0 unsafe.Pointer
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func(uintptr) (unsafe.Pointer, error)); ok {
60+ return returnFunc(size)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(uintptr) unsafe.Pointer); ok {
63+ r0 = returnFunc(size)
64+ } else {
65+ r0 = ret.Get(0).(unsafe.Pointer)
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(uintptr) error); ok {
68+ r1 = returnFunc(size)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// mocknativeMemory_alloc_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'alloc'
76+type mocknativeMemory_alloc_Call struct {
77+ *mock.Call
78+}
79+
80+// alloc is a helper method to define mock.On call
81+// - size uintptr
82+func (_e *mocknativeMemory_Expecter) alloc(size any) *mocknativeMemory_alloc_Call {
83+ return &mocknativeMemory_alloc_Call{Call: _e.mock.On("alloc", size)}
84+}
85+
86+func (_c *mocknativeMemory_alloc_Call) Run(run func(size uintptr)) *mocknativeMemory_alloc_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 uintptr
89+ if args[0] != nil {
90+ arg0 = args[0].(uintptr)
91+ }
92+ run(
93+ arg0,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *mocknativeMemory_alloc_Call) Return(v unsafe.Pointer, err error) *mocknativeMemory_alloc_Call {
100+ _c.Call.Return(v, err)
101+ return _c
102+}
103+
104+func (_c *mocknativeMemory_alloc_Call) RunAndReturn(run func(size uintptr) (unsafe.Pointer, error)) *mocknativeMemory_alloc_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// free provides a mock function for the type mocknativeMemory
110+func (_mock *mocknativeMemory) free(pointer unsafe.Pointer) {
111+ _mock.Called(pointer)
112+ return
113+}
114+
115+// mocknativeMemory_free_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'free'
116+type mocknativeMemory_free_Call struct {
117+ *mock.Call
118+}
119+
120+// free is a helper method to define mock.On call
121+// - pointer unsafe.Pointer
122+func (_e *mocknativeMemory_Expecter) free(pointer any) *mocknativeMemory_free_Call {
123+ return &mocknativeMemory_free_Call{Call: _e.mock.On("free", pointer)}
124+}
125+
126+func (_c *mocknativeMemory_free_Call) Run(run func(pointer unsafe.Pointer)) *mocknativeMemory_free_Call {
127+ _c.Call.Run(func(args mock.Arguments) {
128+ var arg0 unsafe.Pointer
129+ if args[0] != nil {
130+ arg0 = args[0].(unsafe.Pointer)
131+ }
132+ run(
133+ arg0,
134+ )
135+ })
136+ return _c
137+}
138+
139+func (_c *mocknativeMemory_free_Call) Return() *mocknativeMemory_free_Call {
140+ _c.Call.Return()
141+ return _c
142+}
143+
144+func (_c *mocknativeMemory_free_Call) RunAndReturn(run func(pointer unsafe.Pointer)) *mocknativeMemory_free_Call {
145+ _c.Run(run)
146+ return _c
147+}
@@ -0,0 +1,147 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package pam
6+
7+import (
8+ "unsafe"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocknativeMemory creates a new instance of mocknativeMemory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocknativeMemory(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocknativeMemory {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocknativeMemory{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocknativeMemory is an autogenerated mock type for the nativeMemory type
37+type mocknativeMemory struct {
38+ mock.Mock
39+}
40+
41+type mocknativeMemory_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocknativeMemory) EXPECT() *mocknativeMemory_Expecter {
46+ return &mocknativeMemory_Expecter{mock: &_m.Mock}
47+}
48+
49+// alloc provides a mock function for the type mocknativeMemory
50+func (_mock *mocknativeMemory) alloc(size uintptr) (unsafe.Pointer, error) {
51+ ret := _mock.Called(size)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for alloc")
55+ }
56+
57+ var r0 unsafe.Pointer
58+ var r1 error
59+ if returnFunc, ok := ret.Get(0).(func(uintptr) (unsafe.Pointer, error)); ok {
60+ return returnFunc(size)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(uintptr) unsafe.Pointer); ok {
63+ r0 = returnFunc(size)
64+ } else {
65+ r0 = ret.Get(0).(unsafe.Pointer)
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(uintptr) error); ok {
68+ r1 = returnFunc(size)
69+ } else {
70+ r1 = ret.Error(1)
71+ }
72+ return r0, r1
73+}
74+
75+// mocknativeMemory_alloc_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'alloc'
76+type mocknativeMemory_alloc_Call struct {
77+ *mock.Call
78+}
79+
80+// alloc is a helper method to define mock.On call
81+// - size uintptr
82+func (_e *mocknativeMemory_Expecter) alloc(size any) *mocknativeMemory_alloc_Call {
83+ return &mocknativeMemory_alloc_Call{Call: _e.mock.On("alloc", size)}
84+}
85+
86+func (_c *mocknativeMemory_alloc_Call) Run(run func(size uintptr)) *mocknativeMemory_alloc_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 uintptr
89+ if args[0] != nil {
90+ arg0 = args[0].(uintptr)
91+ }
92+ run(
93+ arg0,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *mocknativeMemory_alloc_Call) Return(v unsafe.Pointer, err error) *mocknativeMemory_alloc_Call {
100+ _c.Call.Return(v, err)
101+ return _c
102+}
103+
104+func (_c *mocknativeMemory_alloc_Call) RunAndReturn(run func(size uintptr) (unsafe.Pointer, error)) *mocknativeMemory_alloc_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// free provides a mock function for the type mocknativeMemory
110+func (_mock *mocknativeMemory) free(pointer unsafe.Pointer) {
111+ _mock.Called(pointer)
112+ return
113+}
114+
115+// mocknativeMemory_free_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'free'
116+type mocknativeMemory_free_Call struct {
117+ *mock.Call
118+}
119+
120+// free is a helper method to define mock.On call
121+// - pointer unsafe.Pointer
122+func (_e *mocknativeMemory_Expecter) free(pointer any) *mocknativeMemory_free_Call {
123+ return &mocknativeMemory_free_Call{Call: _e.mock.On("free", pointer)}
124+}
125+
126+func (_c *mocknativeMemory_free_Call) Run(run func(pointer unsafe.Pointer)) *mocknativeMemory_free_Call {
127+ _c.Call.Run(func(args mock.Arguments) {
128+ var arg0 unsafe.Pointer
129+ if args[0] != nil {
130+ arg0 = args[0].(unsafe.Pointer)
131+ }
132+ run(
133+ arg0,
134+ )
135+ })
136+ return _c
137+}
138+
139+func (_c *mocknativeMemory_free_Call) Return() *mocknativeMemory_free_Call {
140+ _c.Call.Return()
141+ return _c
142+}
143+
144+func (_c *mocknativeMemory_free_Call) RunAndReturn(run func(pointer unsafe.Pointer)) *mocknativeMemory_free_Call {
145+ _c.Run(run)
146+ return _c
147+}
A pam_linux.go +204 -0
@@ -0,0 +1,204 @@
1+//go:build linux && (amd64 || arm64)
2+
3+// Package pam provides Linux-PAM binding without cgo. It is not an
4+// authentication policy, credential provider, or cancellable worker.
5+package pam
6+
7+import (
8+ "errors"
9+ "fmt"
10+ "strings"
11+ "unsafe"
12+
13+ "github.com/bnema/purego"
14+)
15+
16+var (
17+ ErrClosed = errors.New("pam: library or transaction is closed")
18+ ErrBusy = errors.New("pam: active transactions prevent library close")
19+ ErrInvalidArgument = errors.New("pam: invalid argument")
20+)
21+
22+// Error preserves the native status without retaining or logging credentials.
23+type Error struct {
24+ Operation string
25+ Status Status
26+}
27+
28+func (e *Error) Error() string {
29+ return fmt.Sprintf("pam: %s failed (status %d)", e.Operation, e.Status)
30+}
31+func statusError(operation string, status Status) error {
32+ if status == Success {
33+ return nil
34+ }
35+ return &Error{Operation: operation, Status: status}
36+}
37+
38+// Library owns loader references. The zero value is closed. Do not copy it.
39+// All operations on a Library and its Transactions must be serialized by one caller/worker.
40+// Native calls are synchronous, may block, and have no cancellation guarantee.
41+type Library struct {
42+ calls libraryFunctions
43+ memory nativeMemory
44+ pamHandle, libcHandle uintptr
45+ active int
46+ closed bool
47+}
48+
49+// Transaction owns a PAM handle and native argument storage until End returns.
50+// Do not copy a Transaction. End must be called exactly once after successful
51+// Start; it consumes the transaction even if pam_end reports an error.
52+type Transaction struct {
53+ library *Library
54+ handle unsafe.Pointer
55+ storage []unsafe.Pointer
56+ last Status
57+ ended bool
58+ bridge *conversationState
59+}
60+
61+// Start requires an explicit service and native conversation; user may be empty
62+// (passed as NULL so PAM may prompt). It never authenticates. No service is
63+// installed by this library. NativeConversation is a trusted unsafe boundary.
64+func (l *Library) Start(service, user string, conversation NativeConversation) (*Transaction, error) {
65+ return l.start(service, user, conversation, nil)
66+}
67+
68+func (l *Library) start(service, user string, conversation NativeConversation, bridge *conversationState) (*Transaction, error) {
69+ if l == nil || l.closed || l.calls == nil || l.memory == nil {
70+ return nil, ErrClosed
71+ }
72+ if service == "" || strings.ContainsRune(service, '\x00') || strings.ContainsRune(user, '\x00') || conversation.Function == 0 {
73+ return nil, ErrInvalidArgument
74+ }
75+ t := &Transaction{library: l, last: Abort, bridge: bridge}
76+ keep := func(p unsafe.Pointer, err error) (unsafe.Pointer, error) {
77+ if err == nil {
78+ t.storage = append(t.storage, p)
79+ }
80+ return p, err
81+ }
82+ servicePtr, err := keep(nativeCString(l.memory, service))
83+ if err != nil {
84+ return nil, err
85+ }
86+ var userPtr unsafe.Pointer
87+ if user != "" {
88+ userPtr, err = keep(nativeCString(l.memory, user))
89+ if err != nil {
90+ t.release()
91+ return nil, err
92+ }
93+ }
94+ convPtr, err := keep(l.memory.alloc(unsafe.Sizeof(nativeConversation{})))
95+ if err != nil {
96+ t.release()
97+ return nil, err
98+ }
99+ if bridge == nil {
100+ *(*nativeConversation)(convPtr) = nativeConversation(conversation)
101+ } else {
102+ // Write opaque cookie bits without converting them to a Go pointer.
103+ *(*nativeCookieConversation)(convPtr) = nativeCookieConversation{Function: conversation.Function, Cookie: bridge.token}
104+ }
105+ output, err := keep(l.memory.alloc(unsafe.Sizeof(uintptr(0))))
106+ if err != nil {
107+ t.release()
108+ return nil, err
109+ }
110+ status := l.calls.Start(servicePtr, userPtr, convPtr, output)
111+ if status != Success {
112+ // pam_start(3): output is undefined on failure. Never read it or
113+ // call pam_end with it; libpam owns its failed-start cleanup.
114+ t.release()
115+ return nil, statusError("start", status)
116+ }
117+ t.handle = *(*unsafe.Pointer)(output)
118+ if t.handle == nil {
119+ t.release()
120+ return nil, errors.New("pam: start succeeded with a NULL handle")
121+ }
122+ t.last = Success
123+ l.active++
124+ return t, nil
125+}
126+
127+func (t *Transaction) valid() bool {
128+ return t != nil && !t.ended && t.library != nil && t.handle != nil
129+}
130+
131+// Authenticate returns an error for every status other than PAM_SUCCESS.
132+// Success here is NOT an account authorization decision; call AcctMgmt too.
133+func (t *Transaction) Authenticate(flags Flags) error {
134+ if !t.valid() {
135+ return ErrClosed
136+ }
137+ if flags & ^(Silent|DisallowNullAuthtok) != 0 {
138+ t.last = SystemErr
139+ return ErrInvalidArgument
140+ }
141+ t.last = t.library.calls.Authenticate(t.handle, flags)
142+ return statusError("authenticate", t.last)
143+}
144+
145+// AcctMgmt fails closed, including PAM_NEW_AUTHTOK_REQD and PAM_INCOMPLETE.
146+func (t *Transaction) AcctMgmt(flags Flags) error {
147+ if !t.valid() {
148+ return ErrClosed
149+ }
150+ if flags & ^(Silent|DisallowNullAuthtok) != 0 {
151+ t.last = SystemErr
152+ return ErrInvalidArgument
153+ }
154+ t.last = t.library.calls.AcctMgmt(t.handle, flags)
155+ return statusError("acct_mgmt", t.last)
156+}
157+
158+// End passes the last operation's status to pam_end and releases native storage
159+// only after that call returns. A second End returns ErrClosed, never calls C.
160+func (t *Transaction) End() error {
161+ if !t.valid() {
162+ return ErrClosed
163+ }
164+ t.ended = true
165+ status := t.library.calls.End(t.handle, t.last)
166+ t.handle = nil
167+ t.release()
168+ t.library.active--
169+ return statusError("end", status)
170+}
171+
172+func (t *Transaction) release() {
173+ if t.bridge != nil {
174+ t.bridge.retire()
175+ t.bridge = nil
176+ }
177+ for i := len(t.storage) - 1; i >= 0; i-- {
178+ t.library.memory.free(t.storage[i])
179+ }
180+ t.storage = nil
181+}
182+
183+// Close refuses to unload symbols still needed by live transactions.
184+func (l *Library) Close() error {
185+ if l == nil || l.closed || l.calls == nil {
186+ return ErrClosed
187+ }
188+ if l.active != 0 {
189+ return ErrBusy
190+ }
191+ l.closed = true
192+ var err error
193+ if l.pamHandle != 0 {
194+ err = errorsWithClose(err, l.pamHandle)
195+ }
196+ if l.libcHandle != 0 {
197+ err = errorsWithClose(err, l.libcHandle)
198+ }
199+ return err
200+}
201+
202+func errorsWithClose(err error, handle uintptr) error {
203+ return errors.Join(err, purego.Dlclose(handle))
204+}
@@ -0,0 +1,204 @@
1+//go:build linux && (amd64 || arm64)
2+
3+// Package pam provides Linux-PAM binding without cgo. It is not an
4+// authentication policy, credential provider, or cancellable worker.
5+package pam
6+
7+import (
8+ "errors"
9+ "fmt"
10+ "strings"
11+ "unsafe"
12+
13+ "github.com/bnema/purego"
14+)
15+
16+var (
17+ ErrClosed = errors.New("pam: library or transaction is closed")
18+ ErrBusy = errors.New("pam: active transactions prevent library close")
19+ ErrInvalidArgument = errors.New("pam: invalid argument")
20+)
21+
22+// Error preserves the native status without retaining or logging credentials.
23+type Error struct {
24+ Operation string
25+ Status Status
26+}
27+
28+func (e *Error) Error() string {
29+ return fmt.Sprintf("pam: %s failed (status %d)", e.Operation, e.Status)
30+}
31+func statusError(operation string, status Status) error {
32+ if status == Success {
33+ return nil
34+ }
35+ return &Error{Operation: operation, Status: status}
36+}
37+
38+// Library owns loader references. The zero value is closed. Do not copy it.
39+// All operations on a Library and its Transactions must be serialized by one caller/worker.
40+// Native calls are synchronous, may block, and have no cancellation guarantee.
41+type Library struct {
42+ calls libraryFunctions
43+ memory nativeMemory
44+ pamHandle, libcHandle uintptr
45+ active int
46+ closed bool
47+}
48+
49+// Transaction owns a PAM handle and native argument storage until End returns.
50+// Do not copy a Transaction. End must be called exactly once after successful
51+// Start; it consumes the transaction even if pam_end reports an error.
52+type Transaction struct {
53+ library *Library
54+ handle unsafe.Pointer
55+ storage []unsafe.Pointer
56+ last Status
57+ ended bool
58+ bridge *conversationState
59+}
60+
61+// Start requires an explicit service and native conversation; user may be empty
62+// (passed as NULL so PAM may prompt). It never authenticates. No service is
63+// installed by this library. NativeConversation is a trusted unsafe boundary.
64+func (l *Library) Start(service, user string, conversation NativeConversation) (*Transaction, error) {
65+ return l.start(service, user, conversation, nil)
66+}
67+
68+func (l *Library) start(service, user string, conversation NativeConversation, bridge *conversationState) (*Transaction, error) {
69+ if l == nil || l.closed || l.calls == nil || l.memory == nil {
70+ return nil, ErrClosed
71+ }
72+ if service == "" || strings.ContainsRune(service, '\x00') || strings.ContainsRune(user, '\x00') || conversation.Function == 0 {
73+ return nil, ErrInvalidArgument
74+ }
75+ t := &Transaction{library: l, last: Abort, bridge: bridge}
76+ keep := func(p unsafe.Pointer, err error) (unsafe.Pointer, error) {
77+ if err == nil {
78+ t.storage = append(t.storage, p)
79+ }
80+ return p, err
81+ }
82+ servicePtr, err := keep(nativeCString(l.memory, service))
83+ if err != nil {
84+ return nil, err
85+ }
86+ var userPtr unsafe.Pointer
87+ if user != "" {
88+ userPtr, err = keep(nativeCString(l.memory, user))
89+ if err != nil {
90+ t.release()
91+ return nil, err
92+ }
93+ }
94+ convPtr, err := keep(l.memory.alloc(unsafe.Sizeof(nativeConversation{})))
95+ if err != nil {
96+ t.release()
97+ return nil, err
98+ }
99+ if bridge == nil {
100+ *(*nativeConversation)(convPtr) = nativeConversation(conversation)
101+ } else {
102+ // Write opaque cookie bits without converting them to a Go pointer.
103+ *(*nativeCookieConversation)(convPtr) = nativeCookieConversation{Function: conversation.Function, Cookie: bridge.token}
104+ }
105+ output, err := keep(l.memory.alloc(unsafe.Sizeof(uintptr(0))))
106+ if err != nil {
107+ t.release()
108+ return nil, err
109+ }
110+ status := l.calls.Start(servicePtr, userPtr, convPtr, output)
111+ if status != Success {
112+ // pam_start(3): output is undefined on failure. Never read it or
113+ // call pam_end with it; libpam owns its failed-start cleanup.
114+ t.release()
115+ return nil, statusError("start", status)
116+ }
117+ t.handle = *(*unsafe.Pointer)(output)
118+ if t.handle == nil {
119+ t.release()
120+ return nil, errors.New("pam: start succeeded with a NULL handle")
121+ }
122+ t.last = Success
123+ l.active++
124+ return t, nil
125+}
126+
127+func (t *Transaction) valid() bool {
128+ return t != nil && !t.ended && t.library != nil && t.handle != nil
129+}
130+
131+// Authenticate returns an error for every status other than PAM_SUCCESS.
132+// Success here is NOT an account authorization decision; call AcctMgmt too.
133+func (t *Transaction) Authenticate(flags Flags) error {
134+ if !t.valid() {
135+ return ErrClosed
136+ }
137+ if flags & ^(Silent|DisallowNullAuthtok) != 0 {
138+ t.last = SystemErr
139+ return ErrInvalidArgument
140+ }
141+ t.last = t.library.calls.Authenticate(t.handle, flags)
142+ return statusError("authenticate", t.last)
143+}
144+
145+// AcctMgmt fails closed, including PAM_NEW_AUTHTOK_REQD and PAM_INCOMPLETE.
146+func (t *Transaction) AcctMgmt(flags Flags) error {
147+ if !t.valid() {
148+ return ErrClosed
149+ }
150+ if flags & ^(Silent|DisallowNullAuthtok) != 0 {
151+ t.last = SystemErr
152+ return ErrInvalidArgument
153+ }
154+ t.last = t.library.calls.AcctMgmt(t.handle, flags)
155+ return statusError("acct_mgmt", t.last)
156+}
157+
158+// End passes the last operation's status to pam_end and releases native storage
159+// only after that call returns. A second End returns ErrClosed, never calls C.
160+func (t *Transaction) End() error {
161+ if !t.valid() {
162+ return ErrClosed
163+ }
164+ t.ended = true
165+ status := t.library.calls.End(t.handle, t.last)
166+ t.handle = nil
167+ t.release()
168+ t.library.active--
169+ return statusError("end", status)
170+}
171+
172+func (t *Transaction) release() {
173+ if t.bridge != nil {
174+ t.bridge.retire()
175+ t.bridge = nil
176+ }
177+ for i := len(t.storage) - 1; i >= 0; i-- {
178+ t.library.memory.free(t.storage[i])
179+ }
180+ t.storage = nil
181+}
182+
183+// Close refuses to unload symbols still needed by live transactions.
184+func (l *Library) Close() error {
185+ if l == nil || l.closed || l.calls == nil {
186+ return ErrClosed
187+ }
188+ if l.active != 0 {
189+ return ErrBusy
190+ }
191+ l.closed = true
192+ var err error
193+ if l.pamHandle != 0 {
194+ err = errorsWithClose(err, l.pamHandle)
195+ }
196+ if l.libcHandle != 0 {
197+ err = errorsWithClose(err, l.libcHandle)
198+ }
199+ return err
200+}
201+
202+func errorsWithClose(err error, handle uintptr) error {
203+ return errors.Join(err, purego.Dlclose(handle))
204+}
A pam_linux_test.go +219 -0
@@ -0,0 +1,219 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "testing"
9+ "unsafe"
10+
11+ "github.com/bnema/purego"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// Real libc allocations plus a generated PAM seam; no real PAM transaction.
17+func testLibrary(t *testing.T) (*Library, *mocklibraryFunctions, unsafe.Pointer) {
18+ t.Helper()
19+ l, err := Open()
20+ require.NoError(t, err)
21+ t.Cleanup(func() { require.NoError(t, l.Close()) })
22+ handle, err := l.memory.alloc(8)
23+ require.NoError(t, err)
24+ t.Cleanup(func() { l.memory.free(handle) })
25+ calls := newMocklibraryFunctions(t)
26+ l.calls = calls
27+ return l, calls, handle
28+}
29+
30+func expectStart(t *testing.T, calls *mocklibraryFunctions, handle unsafe.Pointer, status Status) {
31+ t.Helper()
32+ calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
33+ RunAndReturn(func(service, user, conv, output unsafe.Pointer) Status {
34+ require.Equal(t, []byte("unit-service\x00"), unsafe.Slice((*byte)(service), 13))
35+ require.Equal(t, []byte("unit-user\x00"), unsafe.Slice((*byte)(user), 10))
36+ require.Equal(t, uintptr(1), (*nativeConversation)(conv).Function)
37+ require.Nil(t, (*nativeConversation)(conv).Data)
38+ require.Nil(t, *(*unsafe.Pointer)(output))
39+ *(*unsafe.Pointer)(output) = handle
40+ return status
41+ }).Once()
42+}
43+
44+func TestLoadSymbolsAndNativeMemory(t *testing.T) {
45+ l, err := Open()
46+ require.NoError(t, err)
47+ defer func() { require.NoError(t, l.Close()) }()
48+ for _, name := range []string{"pam_start", "pam_authenticate", "pam_acct_mgmt", "pam_end"} {
49+ address, err := purego.Dlsym(l.pamHandle, name)
50+ require.NoError(t, err)
51+ require.NotZero(t, address)
52+ }
53+ var missing func()
54+ require.Error(t, register(&missing, l.pamHandle, "purego_pam_nonexistent_symbol"))
55+ p, err := l.memory.alloc(32)
56+ require.NoError(t, err)
57+ require.Equal(t, make([]byte, 32), unsafe.Slice((*byte)(p), 32))
58+ l.memory.free(p)
59+ p, err = nativeCString(l.memory, "synthetic")
60+ require.NoError(t, err)
61+ require.Equal(t, []byte("synthetic\x00"), unsafe.Slice((*byte)(p), 10))
62+ l.memory.free(p)
63+ _, err = nativeCString(l.memory, "bad\x00text")
64+ require.ErrorIs(t, err, ErrInvalidArgument)
65+}
66+
67+func TestNativeLayouts(t *testing.T) {
68+ require.Equal(t, uintptr(4), unsafe.Sizeof(Status(0)))
69+ require.Equal(t, uintptr(4), unsafe.Sizeof(Flags(0)))
70+ require.Equal(t, uintptr(8), unsafe.Sizeof(uintptr(0)))
71+ require.Equal(t, uintptr(16), unsafe.Sizeof(nativeMessage{}))
72+ require.Equal(t, uintptr(16), unsafe.Sizeof(nativeResponse{}))
73+ require.Equal(t, uintptr(16), unsafe.Sizeof(nativeConversation{}))
74+ require.Equal(t, uintptr(8), unsafe.Offsetof(nativeMessage{}.Text))
75+ require.Equal(t, uintptr(8), unsafe.Offsetof(nativeResponse{}.ReturnCode))
76+ require.Equal(t, uintptr(8), unsafe.Offsetof(nativeConversation{}.Data))
77+ require.Equal(t, uintptr(8), unsafe.Alignof(nativeMessage{}))
78+ require.Equal(t, uintptr(8), unsafe.Alignof(nativeResponse{}))
79+ require.Equal(t, uintptr(8), unsafe.Alignof(nativeConversation{}))
80+}
81+
82+func TestTransactionLifecycle(t *testing.T) {
83+ l, calls, handle := testLibrary(t)
84+ expectStart(t, calls, handle, Success)
85+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
86+ require.NoError(t, err)
87+ require.ErrorIs(t, l.Close(), ErrBusy)
88+ calls.EXPECT().Authenticate(handle, DisallowNullAuthtok).Return(Success).Once()
89+ require.NoError(t, tx.Authenticate(DisallowNullAuthtok))
90+ calls.EXPECT().AcctMgmt(handle, Flags(0)).Return(Success).Once()
91+ require.NoError(t, tx.AcctMgmt(0))
92+ calls.EXPECT().End(handle, Success).RunAndReturn(func(_ unsafe.Pointer, _ Status) Status {
93+ // Native arguments are still alive while pam_end executes.
94+ require.Len(t, tx.storage, 4)
95+ require.Equal(t, []byte("unit-service\x00"), unsafe.Slice((*byte)(tx.storage[0]), 13))
96+ return Success
97+ }).Once()
98+ require.NoError(t, tx.End())
99+ require.Empty(t, tx.storage)
100+ require.Zero(t, l.active)
101+ require.ErrorIs(t, tx.End(), ErrClosed)
102+ require.ErrorIs(t, tx.Authenticate(0), ErrClosed)
103+ require.ErrorIs(t, tx.AcctMgmt(0), ErrClosed)
104+}
105+
106+func TestEveryNonSuccessFailsClosed(t *testing.T) {
107+ for status := Status(1); status <= Incomplete+1; status++ {
108+ for _, operation := range []string{"authenticate", "acct_mgmt"} {
109+ t.Run(fmt.Sprintf("%s/%d", operation, status), func(t *testing.T) {
110+ l, calls, handle := testLibrary(t)
111+ expectStart(t, calls, handle, Success)
112+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
113+ require.NoError(t, err)
114+ if operation == "authenticate" {
115+ calls.EXPECT().Authenticate(handle, Flags(0)).Return(status).Once()
116+ err = tx.Authenticate(0)
117+ } else {
118+ calls.EXPECT().AcctMgmt(handle, Flags(0)).Return(status).Once()
119+ err = tx.AcctMgmt(0)
120+ }
121+ var nativeErr *Error
122+ require.ErrorAs(t, err, &nativeErr)
123+ require.Equal(t, status, nativeErr.Status)
124+ require.Equal(t, operation, nativeErr.Operation)
125+ calls.EXPECT().End(handle, status).Return(Success).Once()
126+ require.NoError(t, tx.End())
127+ })
128+ }
129+ }
130+}
131+
132+func TestFailedStartAndEnd(t *testing.T) {
133+ for _, tc := range []struct {
134+ name string
135+ status Status
136+ output bool
137+ }{
138+ {"failure-with-undefined-output", ServiceErr, true},
139+ {"failure-without-handle", BufErr, false},
140+ {"success-without-handle", Success, false},
141+ } {
142+ t.Run(tc.name, func(t *testing.T) {
143+ l, calls, handle := testLibrary(t)
144+ var output unsafe.Pointer
145+ if tc.output {
146+ // Output is undefined on failure: no End expectation means
147+ // attempting native cleanup with this value fails the test.
148+ output = handle
149+ }
150+ expectStart(t, calls, output, tc.status)
151+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
152+ require.Error(t, err)
153+ require.Nil(t, tx)
154+ require.Zero(t, l.active)
155+ if tc.output {
156+ var nativeErr *Error
157+ require.True(t, errors.As(err, &nativeErr))
158+ require.Equal(t, tc.status, nativeErr.Status)
159+ }
160+ })
161+ }
162+ t.Run("end-error-consumes-handle", func(t *testing.T) {
163+ l, calls, handle := testLibrary(t)
164+ expectStart(t, calls, handle, Success)
165+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
166+ require.NoError(t, err)
167+ calls.EXPECT().End(handle, Success).Return(SystemErr).Once()
168+ require.Error(t, tx.End())
169+ require.Empty(t, tx.storage)
170+ require.Zero(t, l.active)
171+ require.ErrorIs(t, tx.End(), ErrClosed)
172+ })
173+}
174+
175+func TestArgumentAndClosedValidation(t *testing.T) {
176+ l, calls, handle := testLibrary(t)
177+ for _, tc := range []struct {
178+ service, user string
179+ conv NativeConversation
180+ }{
181+ {"", "user", NativeConversation{Function: 1}},
182+ {"bad\x00service", "user", NativeConversation{Function: 1}},
183+ {"service", "bad\x00user", NativeConversation{Function: 1}},
184+ {"service", "user", NativeConversation{}},
185+ } {
186+ _, err := l.Start(tc.service, tc.user, tc.conv)
187+ require.ErrorIs(t, err, ErrInvalidArgument)
188+ }
189+ expectStart(t, calls, handle, Success)
190+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
191+ require.NoError(t, err)
192+ require.ErrorIs(t, tx.Authenticate(Flags(0x4000)), ErrInvalidArgument)
193+ require.ErrorIs(t, tx.AcctMgmt(Flags(-1)), ErrInvalidArgument)
194+ calls.EXPECT().End(handle, SystemErr).Return(Success).Once()
195+ require.NoError(t, tx.End())
196+ var zero Library
197+ _, err = zero.Start("service", "user", NativeConversation{Function: 1})
198+ require.ErrorIs(t, err, ErrClosed)
199+ require.ErrorIs(t, zero.Close(), ErrClosed)
200+ var absent *Transaction
201+ require.ErrorIs(t, absent.End(), ErrClosed)
202+}
203+
204+func TestEmptyUserAndClosedLibrary(t *testing.T) {
205+ l, calls, handle := testLibrary(t)
206+ calls.EXPECT().Start(mock.Anything, unsafe.Pointer(nil), mock.Anything, mock.Anything).
207+ RunAndReturn(func(_, _, _, output unsafe.Pointer) Status { *(*unsafe.Pointer)(output) = handle; return Success }).Once()
208+ tx, err := l.Start("unit-service", "", NativeConversation{Function: 1})
209+ require.NoError(t, err)
210+ calls.EXPECT().End(handle, Success).Return(Success).Once()
211+ require.NoError(t, tx.End())
212+ // Close is checked separately so fixture cleanup still frees through libc.
213+ other, err := Open()
214+ require.NoError(t, err)
215+ require.NoError(t, other.Close())
216+ require.ErrorIs(t, other.Close(), ErrClosed)
217+ _, err = other.Start("service", "user", NativeConversation{Function: 1})
218+ require.ErrorIs(t, err, ErrClosed)
219+}
@@ -0,0 +1,219 @@
1+//go:build linux && (amd64 || arm64)
2+
3+package pam
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "testing"
9+ "unsafe"
10+
11+ "github.com/bnema/purego"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// Real libc allocations plus a generated PAM seam; no real PAM transaction.
17+func testLibrary(t *testing.T) (*Library, *mocklibraryFunctions, unsafe.Pointer) {
18+ t.Helper()
19+ l, err := Open()
20+ require.NoError(t, err)
21+ t.Cleanup(func() { require.NoError(t, l.Close()) })
22+ handle, err := l.memory.alloc(8)
23+ require.NoError(t, err)
24+ t.Cleanup(func() { l.memory.free(handle) })
25+ calls := newMocklibraryFunctions(t)
26+ l.calls = calls
27+ return l, calls, handle
28+}
29+
30+func expectStart(t *testing.T, calls *mocklibraryFunctions, handle unsafe.Pointer, status Status) {
31+ t.Helper()
32+ calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
33+ RunAndReturn(func(service, user, conv, output unsafe.Pointer) Status {
34+ require.Equal(t, []byte("unit-service\x00"), unsafe.Slice((*byte)(service), 13))
35+ require.Equal(t, []byte("unit-user\x00"), unsafe.Slice((*byte)(user), 10))
36+ require.Equal(t, uintptr(1), (*nativeConversation)(conv).Function)
37+ require.Nil(t, (*nativeConversation)(conv).Data)
38+ require.Nil(t, *(*unsafe.Pointer)(output))
39+ *(*unsafe.Pointer)(output) = handle
40+ return status
41+ }).Once()
42+}
43+
44+func TestLoadSymbolsAndNativeMemory(t *testing.T) {
45+ l, err := Open()
46+ require.NoError(t, err)
47+ defer func() { require.NoError(t, l.Close()) }()
48+ for _, name := range []string{"pam_start", "pam_authenticate", "pam_acct_mgmt", "pam_end"} {
49+ address, err := purego.Dlsym(l.pamHandle, name)
50+ require.NoError(t, err)
51+ require.NotZero(t, address)
52+ }
53+ var missing func()
54+ require.Error(t, register(&missing, l.pamHandle, "purego_pam_nonexistent_symbol"))
55+ p, err := l.memory.alloc(32)
56+ require.NoError(t, err)
57+ require.Equal(t, make([]byte, 32), unsafe.Slice((*byte)(p), 32))
58+ l.memory.free(p)
59+ p, err = nativeCString(l.memory, "synthetic")
60+ require.NoError(t, err)
61+ require.Equal(t, []byte("synthetic\x00"), unsafe.Slice((*byte)(p), 10))
62+ l.memory.free(p)
63+ _, err = nativeCString(l.memory, "bad\x00text")
64+ require.ErrorIs(t, err, ErrInvalidArgument)
65+}
66+
67+func TestNativeLayouts(t *testing.T) {
68+ require.Equal(t, uintptr(4), unsafe.Sizeof(Status(0)))
69+ require.Equal(t, uintptr(4), unsafe.Sizeof(Flags(0)))
70+ require.Equal(t, uintptr(8), unsafe.Sizeof(uintptr(0)))
71+ require.Equal(t, uintptr(16), unsafe.Sizeof(nativeMessage{}))
72+ require.Equal(t, uintptr(16), unsafe.Sizeof(nativeResponse{}))
73+ require.Equal(t, uintptr(16), unsafe.Sizeof(nativeConversation{}))
74+ require.Equal(t, uintptr(8), unsafe.Offsetof(nativeMessage{}.Text))
75+ require.Equal(t, uintptr(8), unsafe.Offsetof(nativeResponse{}.ReturnCode))
76+ require.Equal(t, uintptr(8), unsafe.Offsetof(nativeConversation{}.Data))
77+ require.Equal(t, uintptr(8), unsafe.Alignof(nativeMessage{}))
78+ require.Equal(t, uintptr(8), unsafe.Alignof(nativeResponse{}))
79+ require.Equal(t, uintptr(8), unsafe.Alignof(nativeConversation{}))
80+}
81+
82+func TestTransactionLifecycle(t *testing.T) {
83+ l, calls, handle := testLibrary(t)
84+ expectStart(t, calls, handle, Success)
85+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
86+ require.NoError(t, err)
87+ require.ErrorIs(t, l.Close(), ErrBusy)
88+ calls.EXPECT().Authenticate(handle, DisallowNullAuthtok).Return(Success).Once()
89+ require.NoError(t, tx.Authenticate(DisallowNullAuthtok))
90+ calls.EXPECT().AcctMgmt(handle, Flags(0)).Return(Success).Once()
91+ require.NoError(t, tx.AcctMgmt(0))
92+ calls.EXPECT().End(handle, Success).RunAndReturn(func(_ unsafe.Pointer, _ Status) Status {
93+ // Native arguments are still alive while pam_end executes.
94+ require.Len(t, tx.storage, 4)
95+ require.Equal(t, []byte("unit-service\x00"), unsafe.Slice((*byte)(tx.storage[0]), 13))
96+ return Success
97+ }).Once()
98+ require.NoError(t, tx.End())
99+ require.Empty(t, tx.storage)
100+ require.Zero(t, l.active)
101+ require.ErrorIs(t, tx.End(), ErrClosed)
102+ require.ErrorIs(t, tx.Authenticate(0), ErrClosed)
103+ require.ErrorIs(t, tx.AcctMgmt(0), ErrClosed)
104+}
105+
106+func TestEveryNonSuccessFailsClosed(t *testing.T) {
107+ for status := Status(1); status <= Incomplete+1; status++ {
108+ for _, operation := range []string{"authenticate", "acct_mgmt"} {
109+ t.Run(fmt.Sprintf("%s/%d", operation, status), func(t *testing.T) {
110+ l, calls, handle := testLibrary(t)
111+ expectStart(t, calls, handle, Success)
112+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
113+ require.NoError(t, err)
114+ if operation == "authenticate" {
115+ calls.EXPECT().Authenticate(handle, Flags(0)).Return(status).Once()
116+ err = tx.Authenticate(0)
117+ } else {
118+ calls.EXPECT().AcctMgmt(handle, Flags(0)).Return(status).Once()
119+ err = tx.AcctMgmt(0)
120+ }
121+ var nativeErr *Error
122+ require.ErrorAs(t, err, &nativeErr)
123+ require.Equal(t, status, nativeErr.Status)
124+ require.Equal(t, operation, nativeErr.Operation)
125+ calls.EXPECT().End(handle, status).Return(Success).Once()
126+ require.NoError(t, tx.End())
127+ })
128+ }
129+ }
130+}
131+
132+func TestFailedStartAndEnd(t *testing.T) {
133+ for _, tc := range []struct {
134+ name string
135+ status Status
136+ output bool
137+ }{
138+ {"failure-with-undefined-output", ServiceErr, true},
139+ {"failure-without-handle", BufErr, false},
140+ {"success-without-handle", Success, false},
141+ } {
142+ t.Run(tc.name, func(t *testing.T) {
143+ l, calls, handle := testLibrary(t)
144+ var output unsafe.Pointer
145+ if tc.output {
146+ // Output is undefined on failure: no End expectation means
147+ // attempting native cleanup with this value fails the test.
148+ output = handle
149+ }
150+ expectStart(t, calls, output, tc.status)
151+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
152+ require.Error(t, err)
153+ require.Nil(t, tx)
154+ require.Zero(t, l.active)
155+ if tc.output {
156+ var nativeErr *Error
157+ require.True(t, errors.As(err, &nativeErr))
158+ require.Equal(t, tc.status, nativeErr.Status)
159+ }
160+ })
161+ }
162+ t.Run("end-error-consumes-handle", func(t *testing.T) {
163+ l, calls, handle := testLibrary(t)
164+ expectStart(t, calls, handle, Success)
165+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
166+ require.NoError(t, err)
167+ calls.EXPECT().End(handle, Success).Return(SystemErr).Once()
168+ require.Error(t, tx.End())
169+ require.Empty(t, tx.storage)
170+ require.Zero(t, l.active)
171+ require.ErrorIs(t, tx.End(), ErrClosed)
172+ })
173+}
174+
175+func TestArgumentAndClosedValidation(t *testing.T) {
176+ l, calls, handle := testLibrary(t)
177+ for _, tc := range []struct {
178+ service, user string
179+ conv NativeConversation
180+ }{
181+ {"", "user", NativeConversation{Function: 1}},
182+ {"bad\x00service", "user", NativeConversation{Function: 1}},
183+ {"service", "bad\x00user", NativeConversation{Function: 1}},
184+ {"service", "user", NativeConversation{}},
185+ } {
186+ _, err := l.Start(tc.service, tc.user, tc.conv)
187+ require.ErrorIs(t, err, ErrInvalidArgument)
188+ }
189+ expectStart(t, calls, handle, Success)
190+ tx, err := l.Start("unit-service", "unit-user", NativeConversation{Function: 1})
191+ require.NoError(t, err)
192+ require.ErrorIs(t, tx.Authenticate(Flags(0x4000)), ErrInvalidArgument)
193+ require.ErrorIs(t, tx.AcctMgmt(Flags(-1)), ErrInvalidArgument)
194+ calls.EXPECT().End(handle, SystemErr).Return(Success).Once()
195+ require.NoError(t, tx.End())
196+ var zero Library
197+ _, err = zero.Start("service", "user", NativeConversation{Function: 1})
198+ require.ErrorIs(t, err, ErrClosed)
199+ require.ErrorIs(t, zero.Close(), ErrClosed)
200+ var absent *Transaction
201+ require.ErrorIs(t, absent.End(), ErrClosed)
202+}
203+
204+func TestEmptyUserAndClosedLibrary(t *testing.T) {
205+ l, calls, handle := testLibrary(t)
206+ calls.EXPECT().Start(mock.Anything, unsafe.Pointer(nil), mock.Anything, mock.Anything).
207+ RunAndReturn(func(_, _, _, output unsafe.Pointer) Status { *(*unsafe.Pointer)(output) = handle; return Success }).Once()
208+ tx, err := l.Start("unit-service", "", NativeConversation{Function: 1})
209+ require.NoError(t, err)
210+ calls.EXPECT().End(handle, Success).Return(Success).Once()
211+ require.NoError(t, tx.End())
212+ // Close is checked separately so fixture cleanup still frees through libc.
213+ other, err := Open()
214+ require.NoError(t, err)
215+ require.NoError(t, other.Close())
216+ require.ErrorIs(t, other.Close(), ErrClosed)
217+ _, err = other.Start("service", "user", NativeConversation{Function: 1})
218+ require.ErrorIs(t, err, ErrClosed)
219+}

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