Merge pull request #1 from bnema/feat/pam-binding
feat(pam): add purego Linux-PAM binding with managed conversation
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 | + | } | |||