changeset 74:c7c596e6388f

Make conversations type-safe (last big reorg) (REAL) (NOT CLICKBAIT) In previous versions of Conversation, you could send messages and then return messages of the wrong type or in the wrong order or whatever. The receiver would then have to make sure that there were the right number of messages and that each message was the right type. That's annoying. This change makes the `Message` enum a two-way channel, where the asker puts their question into it, and then the answerer (the conversation) puts the answer in and returns control to the asker. The asker then only has to pull the Answer of the type they wanted out of the message.
author Paul Fisher <paul@pfish.zone>
date Fri, 06 Jun 2025 22:21:17 -0400
parents ac6881304c78
children c30811b4afae
files Cargo.toml src/conv.rs src/lib.rs src/module.rs src/pam_ffi/conversation.rs src/pam_ffi/handle.rs src/pam_ffi/message.rs src/pam_ffi/mod.rs src/pam_ffi/module.rs
diffstat 9 files changed, 617 insertions(+), 455 deletions(-) [+]
line wrap: on
line diff
--- a/Cargo.toml	Thu Jun 05 03:41:38 2025 -0400
+++ b/Cargo.toml	Fri Jun 06 22:21:17 2025 -0400
@@ -9,9 +9,12 @@
 license = "MIT"
 edition = "2021"
 
+[features]
+# Enable this to actually link against your system's PAM library.
+link = []
+
 [dependencies]
 bitflags = "2.9.0"
-derive_more = { version = "2.0.1", features = ["from"] }
 libc = "0.2.97"
 num-derive = "0.4.2"
 num-traits = "0.2.19"
--- a/src/conv.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/conv.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -6,93 +6,244 @@
 use crate::constants::Result;
 use crate::ErrorCode;
 use secure_string::SecureString;
-// TODO: In most cases, we should be passing around references to strings
-// or binary data. Right now we don't because that turns type inference and
-// trait definitions/implementations into a HUGE MESS.
-//
-// Ideally, we would be using some kind of `TD: TextData` and `BD: BinaryData`
-// associated types in the various Conversation traits to avoid copying
-// when unnecessary.
+use std::cell::Cell;
 
 /// The types of message and request that can be sent to a user.
 ///
 /// The data within each enum value is the prompt (or other information)
 /// that will be presented to the user.
-#[derive(Clone, Copy, Debug)]
 pub enum Message<'a> {
-    /// Requests information from the user; will be masked when typing.
-    ///
-    /// Response: [`MaskedText`](Response::MaskedText)
-    MaskedPrompt(&'a str),
-    /// Requests information from the user; will not be masked.
+    MaskedPrompt(&'a MaskedPrompt<'a>),
+    Prompt(&'a Prompt<'a>),
+    RadioPrompt(&'a RadioPrompt<'a>),
+    BinaryPrompt(&'a BinaryPrompt<'a>),
+    InfoMsg(&'a InfoMsg<'a>),
+    ErrorMsg(&'a ErrorMsg<'a>),
+}
+
+/// A question-and-answer pair that can be communicated in a [`Conversation`].
+///
+/// The asking side creates a `QAndA`, then converts it to a [`Message`]
+/// and sends it via a [`Conversation`]. The Conversation then retrieves
+/// the answer to the question (if needed) and sets the response.
+/// Once control returns to the asker, the asker gets the answer from this
+/// `QAndA` and uses it however it wants.
+///
+/// For a more detailed explanation of how this works,
+/// see [`Conversation::communicate`].
+pub trait QAndA<'a> {
+    /// The type of the content of the question.
+    type Question: Copy;
+    /// The type of the answer to the question.
+    type Answer;
+
+    /// Converts this Q-and-A pair into a [`Message`] for the [`Conversation`].
+    fn message(&self) -> Message;
+
+    /// The contents of the question being asked.
     ///
-    /// Response: [`Text`](Response::Text)
-    Prompt(&'a str),
-    /// "Yes/No/Maybe conditionals" (a Linux-PAM extension).
-    ///
-    /// Response: [`Text`](Response::Text)
-    /// (Linux-PAM documentation doesn't define its contents.)
-    RadioPrompt(&'a str),
-    /// Raises an error message to the user.
+    /// For instance, this might say `"Username:"` to prompt the user
+    /// for their name.
+    fn question(&self) -> Self::Question;
+
+    /// Sets the answer to the question.
     ///
-    /// Response: [`NoResponse`](Response::NoResponse)
-    Error(&'a str),
-    /// Sends an informational message to the user.
-    ///
-    /// Response: [`NoResponse`](Response::NoResponse)
-    Info(&'a str),
-    /// Requests binary data from the client (a Linux-PAM extension).
-    ///
-    /// This is used for non-human or non-keyboard prompts (security key?).
-    /// NOT part of the X/Open PAM specification.
-    ///
-    /// Response: [`Binary`](Response::Binary)
-    BinaryPrompt {
-        /// Some binary data.
-        data: &'a [u8],
-        /// A "type" that you can use for signalling. Has no strict definition in PAM.
-        data_type: u8,
-    },
+    /// The [`Conversation`] implementation calls this to set the answer.
+    /// The conversation should *always call this function*, even for messages
+    /// that don't have "an answer" (like error or info messages).
+    fn set_answer(&self, answer: Result<Self::Answer>);
+
+    /// Gets the answer to the question.
+    fn answer(self) -> Result<Self::Answer>;
+}
+
+macro_rules! q_and_a {
+    ($name:ident<'a, Q=$qt:ty, A=$at:ty>, $($doc:literal)*) => {
+        $(
+            #[doc = $doc]
+        )*
+        pub struct $name<'a> {
+            q: $qt,
+            a: Cell<Result<$at>>,
+        }
+
+        impl<'a> QAndA<'a> for $name<'a> {
+            type Question = $qt;
+            type Answer = $at;
+
+            fn question(&self) -> Self::Question {
+                self.q
+            }
+
+            fn set_answer(&self, answer: Result<Self::Answer>) {
+                self.a.set(answer)
+            }
+
+            fn answer(self) -> Result<Self::Answer> {
+                self.a.into_inner()
+            }
+
+            fn message(&self) -> Message {
+                Message::$name(self)
+            }
+        }
+    };
+}
+
+macro_rules! ask {
+    ($t:ident) => {
+        impl<'a> $t<'a> {
+            #[doc = concat!("Creates a `", stringify!($t), "` to be sent to the user.")]
+            fn ask(question: &'a str) -> Self {
+                Self {
+                    q: question,
+                    a: Cell::new(Err(ErrorCode::ConversationError)),
+                }
+            }
+        }
+    };
 }
 
-/// The responses that PAM will return from a request.
-#[derive(Debug, PartialEq, derive_more::From)]
-pub enum Response {
-    /// Used to fill in list entries where there is no response expected.
-    ///
-    /// Used in response to:
-    ///
-    /// - [`Error`](Message::Error)
-    /// - [`Info`](Message::Info)
-    NoResponse,
-    /// A response with text data from the user.
-    ///
-    /// Used in response to:
+q_and_a!(
+    MaskedPrompt<'a, Q=&'a str, A=SecureString>,
+    "Asks the user for data and does not echo it back while being entered."
+    ""
+    "In other words, a password entry prompt."
+);
+ask!(MaskedPrompt);
+
+q_and_a!(
+    Prompt<'a, Q=&'a str, A=String>,
+    "Asks the user for data."
+    ""
+    "This is the normal \"ask a person a question\" prompt."
+    "When the user types, their input will be shown to them."
+    "It can be used for things like usernames."
+);
+ask!(Prompt);
+
+q_and_a!(
+    RadioPrompt<'a, Q=&'a str, A=String>,
+    "Asks the user for \"radio button\"–style data. (Linux-PAM extension)"
+    ""
+    "This message type is theoretically useful for \"yes/no/maybe\""
+    "questions, but nowhere in the documentation is it specified"
+    "what the format of the answer will be, or how this should be shown."
+);
+ask!(RadioPrompt);
+
+q_and_a!(
+    BinaryPrompt<'a, Q=BinaryQuestion<'a>, A=BinaryData>,
+    "Asks for binary data. (Linux-PAM extension)"
+    ""
+    "This sends a binary message to the client application."
+    "It can be used to communicate with non-human logins,"
+    "or to enable things like security keys."
+    ""
+    "The `data_type` tag is a value that is simply passed through"
+    "to the application. PAM does not define any meaning for it."
+);
+impl<'a> BinaryPrompt<'a> {
+    /// Creates a prompt for the given binary data.
     ///
-    /// - [`Prompt`](Message::Prompt)
-    /// - [`RadioPrompt`](Message::RadioPrompt) (a Linux-PAM extension)
-    Text(String),
-    /// A response to a masked request with text data from the user.
-    ///
-    /// Used in response to:
-    ///
-    /// - [`MaskedPrompt`](Message::MaskedPrompt)
-    MaskedText(SecureString),
-    /// A response to a binary request (a Linux-PAM extension).
-    ///
-    /// Used in response to:
-    ///
-    /// - [`BinaryPrompt`](Message::BinaryPrompt)
-    Binary(BinaryData),
+    /// The `data_type` is a tag you can use for communication between
+    /// the module and the application. Its meaning is undefined by PAM.
+    fn ask(data: &'a [u8], data_type: u8) -> Self {
+        Self {
+            q: BinaryQuestion { data, data_type },
+            a: Cell::new(Err(ErrorCode::ConversationError)),
+        }
+    }
+}
+
+/// The contents of a question requesting binary data.
+///
+/// A borrowed version of [`BinaryData`].
+#[derive(Copy, Clone, Debug)]
+pub struct BinaryQuestion<'a> {
+    data: &'a [u8],
+    data_type: u8,
 }
 
-/// The function type for a conversation.
+impl BinaryQuestion<'_> {
+    /// Gets the data of this question.
+    pub fn data(&self) -> &[u8] {
+        self.data
+    }
+
+    /// Gets the "type" of this data.
+    pub fn data_type(&self) -> u8 {
+        self.data_type
+    }
+}
+
+/// Owned binary data.
 ///
-/// A macro to save typing `FnMut(&[Message]) -> Result<Vec<Response>>`.
-#[macro_export]
-macro_rules! conv_type {
-    () => {FnMut(&[Message]) -> Result<Vec<Response>>};
-    (impl) => { impl FnMut(&[Message]) -> Result<Vec<Response>> }
+/// For borrowed data, see [`BinaryQuestion`].
+/// You can take ownership of the stored data with `.into::<Vec<u8>>()`.
+#[derive(Debug, PartialEq)]
+pub struct BinaryData {
+    data: Vec<u8>,
+    data_type: u8,
+}
+
+impl BinaryData {
+    /// Creates a `BinaryData` with the given contents and type.
+    pub fn new(data: Vec<u8>, data_type: u8) -> Self {
+        Self { data, data_type }
+    }
+    /// A borrowed view of the data here.
+    pub fn data(&self) -> &[u8] {
+        &self.data
+    }
+    /// The type of the data stored in this.
+    pub fn data_type(&self) -> u8 {
+        self.data_type
+    }
+}
+
+impl From<BinaryData> for Vec<u8> {
+    /// Takes ownership of the data stored herein.
+    fn from(value: BinaryData) -> Self {
+        value.data
+    }
+}
+
+q_and_a!(
+    InfoMsg<'a, Q = &'a str, A = ()>,
+    "A message containing information to be passed to the user."
+    ""
+    "While this does not have an answer, [`Conversation`] implementations"
+    "should still call [`set_answer`][`QAndA::set_answer`] to verify that"
+    "the message has been displayed (or actively discarded)."
+);
+impl<'a> InfoMsg<'a> {
+    /// Creates an informational message to send to the user.
+    fn new(message: &'a str) -> Self {
+        Self {
+            q: message,
+            a: Cell::new(Err(ErrorCode::ConversationError)),
+        }
+    }
+}
+
+q_and_a!(
+    ErrorMsg<'a, Q = &'a str, A = ()>,
+    "An error message to be passed to the user."
+    ""
+    "While this does not have an answer, [`Conversation`] implementations"
+    "should still call [`set_answer`][`QAndA::set_answer`] to verify that"
+    "the message has been displayed (or actively discarded)."
+
+);
+impl<'a> ErrorMsg<'a> {
+    /// Creates an error message to send to the user.
+    fn new(message: &'a str) -> Self {
+        Self {
+            q: message,
+            a: Cell::new(Err(ErrorCode::ConversationError)),
+        }
+    }
 }
 
 /// A channel for PAM modules to request information from the user.
@@ -108,36 +259,121 @@
     ///
     /// The returned Vec of messages always contains exactly as many entries
     /// as there were messages in the request; one corresponding to each.
-    fn converse(&mut self, messages: &[Message]) -> Result<Vec<Response>>;
+    ///
+    /// TODO: write detailed documentation about how to use this.
+    fn communicate(&mut self, messages: &[Message]);
 }
 
-fn conversation_func(func: conv_type!(impl)) -> impl Conversation {
+/// Turns a simple function into a [`Conversation`].
+///
+/// This can be used to wrap a free-floating function for use as a
+/// Conversation:
+///
+/// ```
+/// use nonstick::conv::{Conversation, Message, conversation_func};
+/// mod some_library {
+/// #    use nonstick::Conversation;
+///     pub fn get_auth_data(conv: &mut impl Conversation) { /* ... */ }
+/// }
+///
+/// fn my_terminal_prompt(messages: &[Message]) {
+///     // ...
+/// #    todo!()
+/// }
+///
+/// fn main() {
+///     some_library::get_auth_data(&mut conversation_func(my_terminal_prompt));
+/// }
+/// ```
+pub fn conversation_func(func: impl FnMut(&[Message])) -> impl Conversation {
     Convo(func)
 }
 
-struct Convo<C: FnMut(&[Message]) -> Result<Vec<Response>>>(C);
+struct Convo<C: FnMut(&[Message])>(C);
 
-impl<C: FnMut(&[Message]) -> Result<Vec<Response>>> Conversation for Convo<C> {
-    fn converse(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
+impl<C: FnMut(&[Message])> Conversation for Convo<C> {
+    fn communicate(&mut self, messages: &[Message]) {
         self.0(messages)
     }
 }
 
-/// Provides methods to make it easier to send exactly one message.
+/// A conversation trait for asking or answering one question at a time.
+///
+/// An implementation of this is provided for any [`Conversation`],
+/// or a PAM application can implement this trait and handle messages
+/// one at a time.
+///
+/// For example, to use a `Conversation` as a `SimpleConversation`:
 ///
-/// This is primarily used by PAM modules, so that a module that only needs
-/// one piece of information at a time doesn't have a ton of boilerplate.
-/// You may also find it useful for testing PAM application libraries.
+/// ```
+/// # use nonstick::{Conversation, Result};
+/// # use secure_string::SecureString;
+/// // Bring this trait into scope to get `masked_prompt`, among others.
+/// use nonstick::SimpleConversation;
+///
+/// fn ask_for_token(convo: &mut impl Conversation) -> Result<SecureString> {
+///     convo.masked_prompt("enter your one-time token")
+/// }
+/// ```
+///
+/// or to use a `SimpleConversation` as a `Conversation`:
 ///
 /// ```
-/// # use nonstick::{PamHandleModule, Conversation, Result};
-/// # fn _do_test(mut pam_handle: impl PamHandleModule) -> Result<()> {
-/// use nonstick::ConversationMux;
+/// use secure_string::SecureString;
+/// use nonstick::{Conversation, SimpleConversation};
+/// # use nonstick::{BinaryData, Result};
+/// mod some_library {
+/// #    use nonstick::Conversation;
+///     pub fn get_auth_data(conv: &mut impl Conversation) { /* ... */ }
+/// }
+///
+/// struct MySimpleConvo { /* ... */ }
+/// # impl MySimpleConvo { fn new() -> Self { Self{} } }
 ///
-/// let token = pam_handle.masked_prompt("enter your one-time token")?;
-/// # Ok(())
+/// impl SimpleConversation for MySimpleConvo {
+///     // ...
+/// # fn prompt(&mut self, request: &str) -> Result<String> {
+/// #     todo!()
+/// # }
+/// #
+/// # fn masked_prompt(&mut self, request: &str) -> Result<SecureString> {
+/// #     todo!()
+/// # }
+/// #
+/// # fn radio_prompt(&mut self, request: &str) -> Result<String> {
+/// #     todo!()
+/// # }
+/// #
+/// # fn error_msg(&mut self, message: &str) {
+/// #     todo!()
+/// # }
+/// #
+/// # fn info_msg(&mut self, message: &str) {
+/// #     todo!()
 /// # }
-pub trait ConversationMux {
+/// #
+/// # fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData> {
+/// #     todo!()
+/// # }
+/// }
+///
+/// fn main() {
+///     let mut simple = MySimpleConvo::new();
+///     some_library::get_auth_data(&mut simple.as_conversation())
+/// }
+/// ```
+pub trait SimpleConversation {
+    /// Lets you use this simple conversation as a full [Conversation].
+    ///
+    /// The wrapper takes each message received in [`Conversation::communicate`]
+    /// and passes them one-by-one to the appropriate method,
+    /// then collects responses to return.
+    fn as_conversation(&mut self) -> Demux<Self>
+    where
+        Self: Sized,
+    {
+        Demux(self)
+    }
     /// Prompts the user for something.
     fn prompt(&mut self, request: &str) -> Result<String>;
     /// Prompts the user for something, but hides what the user types.
@@ -147,141 +383,78 @@
     /// PAM documentation doesn't define the format of the response.
     fn radio_prompt(&mut self, request: &str) -> Result<String>;
     /// Alerts the user to an error.
-    fn error(&mut self, message: &str);
+    fn error_msg(&mut self, message: &str);
     /// Sends an informational message to the user.
-    fn info(&mut self, message: &str);
+    fn info_msg(&mut self, message: &str);
     /// Requests binary data from the user (a Linux-PAM extension).
     fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData>;
 }
 
-impl<C: Conversation> ConversationMux for C {
-    /// Prompts the user for something.
-    fn prompt(&mut self, request: &str) -> Result<String> {
-        let resp = self.converse(&[Message::Prompt(request)])?.pop();
-        match resp {
-            Some(Response::Text(s)) => Ok(s),
-            _ => Err(ErrorCode::ConversationError),
+macro_rules! conv_fn {
+    ($fn_name:ident($($param:ident: $pt:ty),+) -> $resp_type:ty { $ask:path }) => {
+        fn $fn_name(&mut self, $($param: $pt),*) -> Result<$resp_type> {
+            let prompt = $ask($($param),*);
+            self.communicate(&[prompt.message()]);
+            prompt.answer()
         }
-    }
-
-    /// Prompts the user for something, but hides what the user types.
-    fn masked_prompt(&mut self, request: &str) -> Result<SecureString> {
-        let resp = self.converse(&[Message::MaskedPrompt(request)])?.pop();
-        match resp {
-            Some(Response::MaskedText(s)) => Ok(s),
-            _ => Err(ErrorCode::ConversationError),
+    };
+    ($fn_name:ident($($param:ident: $pt:ty),+) { $ask:path }) => {
+        fn $fn_name(&mut self, $($param: $pt),*) {
+            self.communicate(&[$ask($($param),*).message()]);
         }
-    }
+    };
+}
 
-    /// Prompts the user for a yes/no/maybe conditional (a Linux-PAM extension).
-    ///
-    /// PAM documentation doesn't define the format of the response.
-    fn radio_prompt(&mut self, request: &str) -> Result<String> {
-        let resp = self.converse(&[Message::RadioPrompt(request)])?.pop();
-        match resp {
-            Some(Response::Text(s)) => Ok(s),
-            _ => Err(ErrorCode::ConversationError),
-        }
-    }
-
-    /// Alerts the user to an error.
-    fn error(&mut self, message: &str) {
-        let _ = self.converse(&[Message::Error(message)]);
-    }
-
-    /// Sends an informational message to the user.
-    fn info(&mut self, message: &str) {
-        let _ = self.converse(&[Message::Info(message)]);
-    }
-
-    /// Requests binary data from the user (a Linux-PAM extension).
-    fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData> {
-        let resp = self
-            .converse(&[Message::BinaryPrompt { data, data_type }])?
-            .pop();
-        match resp {
-            Some(Response::Binary(d)) => Ok(d),
-            _ => Err(ErrorCode::ConversationError),
-        }
-    }
+impl<C: Conversation> SimpleConversation for C {
+    conv_fn!(prompt(message: &str) -> String { Prompt::ask });
+    conv_fn!(masked_prompt(message: &str) -> SecureString { MaskedPrompt::ask });
+    conv_fn!(radio_prompt(message: &str) -> String { RadioPrompt::ask });
+    conv_fn!(error_msg(message: &str) { ErrorMsg::new });
+    conv_fn!(info_msg(message: &str) { InfoMsg::new });
+    conv_fn!(binary_prompt(data: &[u8], data_type: u8) -> BinaryData { BinaryPrompt::ask });
 }
 
-/// Trait that an application can implement if they want to handle messages
-/// one at a time.
-pub trait DemuxedConversation {
-    /// Prompts the user for some text.
-    fn prompt(&mut self, request: &str) -> Result<String>;
-    /// Prompts the user for some text, but hides their typing.
-    fn masked_prompt(&mut self, request: &str) -> Result<SecureString>;
-    /// Prompts the user for a radio option (a Linux-PAM extension).
-    ///
-    /// The Linux-PAM documentation doesn't give the format of the response.
-    fn radio_prompt(&mut self, request: &str) -> Result<String>;
-    /// Alerts the user to an error.
-    fn error(&mut self, message: &str);
-    /// Sends an informational message to the user.
-    fn info(&mut self, message: &str);
-    /// Requests binary data from the user (a Linux-PAM extension).
-    fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData>;
-}
+/// A [`Conversation`] which asks the questions one at a time.
+///
+/// This is automatically created by [`SimpleConversation::as_conversation`].
+pub struct Demux<'a, SC: SimpleConversation>(&'a mut SC);
 
-impl<DM> Conversation for DM
-where
-    DM: DemuxedConversation,
-{
-    fn converse(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
-        messages
-            .iter()
-            .map(|msg| match *msg {
-                Message::Prompt(prompt) => self.prompt(prompt).map(Response::from),
-                Message::MaskedPrompt(prompt) => self.masked_prompt(prompt).map(Response::from),
-                Message::RadioPrompt(prompt) => self.radio_prompt(prompt).map(Response::from),
-                Message::Info(message) => {
-                    self.info(message);
-                    Ok(Response::NoResponse)
+impl<SC: SimpleConversation> Conversation for Demux<'_, SC> {
+    fn communicate(&mut self, messages: &[Message]) {
+        for msg in messages {
+            match msg {
+                Message::Prompt(prompt) => prompt.set_answer(self.0.prompt(prompt.question())),
+                Message::MaskedPrompt(prompt) => {
+                    prompt.set_answer(self.0.masked_prompt(prompt.question()))
                 }
-                Message::Error(message) => {
-                    self.error(message);
-                    Ok(Response::NoResponse)
+                Message::RadioPrompt(prompt) => {
+                    prompt.set_answer(self.0.radio_prompt(prompt.question()))
                 }
-                Message::BinaryPrompt { data_type, data } => {
-                    self.binary_prompt(data, data_type).map(Response::from)
+                Message::InfoMsg(prompt) => {
+                    self.0.info_msg(prompt.question());
+                    prompt.set_answer(Ok(()))
                 }
-            })
-            .collect()
-    }
-}
-
-/// Owned binary data.
-#[derive(Debug, PartialEq)]
-pub struct BinaryData {
-    data: Vec<u8>,
-    data_type: u8,
-}
-
-impl BinaryData {
-    pub fn new(data: Vec<u8>, data_type: u8) -> Self {
-        Self { data, data_type }
-    }
-    pub fn data(&self) -> &[u8] {
-        &self.data
-    }
-    pub fn data_type(&self) -> u8 {
-        self.data_type
-    }
-}
-
-impl From<BinaryData> for Vec<u8> {
-    /// Extracts the inner vector from the BinaryData.
-    fn from(value: BinaryData) -> Self {
-        value.data
+                Message::ErrorMsg(prompt) => {
+                    self.0.error_msg(prompt.question());
+                    prompt.set_answer(Ok(()))
+                }
+                Message::BinaryPrompt(prompt) => {
+                    let q = prompt.question();
+                    prompt.set_answer(self.0.binary_prompt(q.data, q.data_type))
+                }
+            }
+        }
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use super::{Conversation, DemuxedConversation, Message, Response, SecureString};
+    use super::{
+        BinaryPrompt, Conversation, ErrorMsg, InfoMsg, MaskedPrompt, Message, Prompt, QAndA,
+        RadioPrompt, Result, SecureString, SimpleConversation,
+    };
     use crate::constants::ErrorCode;
+    use crate::BinaryData;
 
     #[test]
     fn test_demux() {
@@ -291,132 +464,116 @@
             info_ran: bool,
         }
 
-        impl DemuxedConversation for DemuxTester {
-            fn prompt(&mut self, request: &str) -> crate::Result<String> {
+        impl SimpleConversation for DemuxTester {
+            fn prompt(&mut self, request: &str) -> Result<String> {
                 match request {
                     "what" => Ok("whatwhat".to_owned()),
                     "give_err" => Err(ErrorCode::PermissionDenied),
                     _ => panic!("unexpected prompt!"),
                 }
             }
-            fn masked_prompt(&mut self, request: &str) -> crate::Result<SecureString> {
+            fn masked_prompt(&mut self, request: &str) -> Result<SecureString> {
                 assert_eq!("reveal", request);
                 Ok(SecureString::from("my secrets"))
             }
-            fn radio_prompt(&mut self, request: &str) -> crate::Result<String> {
+            fn radio_prompt(&mut self, request: &str) -> Result<String> {
                 assert_eq!("channel?", request);
                 Ok("zero".to_owned())
             }
-            fn error(&mut self, message: &str) {
+            fn error_msg(&mut self, message: &str) {
                 self.error_ran = true;
                 assert_eq!("whoopsie", message);
             }
-            fn info(&mut self, message: &str) {
+            fn info_msg(&mut self, message: &str) {
                 self.info_ran = true;
                 assert_eq!("did you know", message);
             }
-            fn binary_prompt(
-                &mut self,
-                data: &[u8],
-                data_type: u8,
-            ) -> crate::Result<super::BinaryData> {
+            fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData> {
                 assert_eq!(&[10, 9, 8], data);
                 assert_eq!(66, data_type);
-                Ok(super::BinaryData::new(vec![5, 5, 5], 5))
+                Ok(BinaryData::new(vec![5, 5, 5], 5))
             }
         }
 
         let mut tester = DemuxTester::default();
 
-        assert_eq!(
-            vec![
-                Response::Text("whatwhat".to_owned()),
-                Response::MaskedText("my secrets".into()),
-                Response::NoResponse,
-                Response::NoResponse,
-            ],
-            tester
-                .converse(&[
-                    Message::Prompt("what"),
-                    Message::MaskedPrompt("reveal"),
-                    Message::Error("whoopsie"),
-                    Message::Info("did you know"),
-                ])
-                .unwrap()
-        );
+        let what = Prompt::ask("what");
+        let pass = MaskedPrompt::ask("reveal");
+        let err = ErrorMsg::new("whoopsie");
+        let info = InfoMsg::new("did you know");
+        let has_err = Prompt::ask("give_err");
+
+        let mut conv = tester.as_conversation();
+
+        // Basic tests.
+
+        conv.communicate(&[
+            what.message(),
+            pass.message(),
+            err.message(),
+            info.message(),
+            has_err.message(),
+        ]);
+
+        assert_eq!("whatwhat", what.answer().unwrap());
+        assert_eq!(SecureString::from("my secrets"), pass.answer().unwrap());
+        assert_eq!(Ok(()), err.answer());
+        assert_eq!(Ok(()), info.answer());
+        assert_eq!(ErrorCode::PermissionDenied, has_err.answer().unwrap_err());
         assert!(tester.error_ran);
         assert!(tester.info_ran);
 
-        assert_eq!(
-            ErrorCode::PermissionDenied,
-            tester.converse(&[Message::Prompt("give_err")]).unwrap_err(),
-        );
+        // Test the Linux extensions separately.
 
-        // Test the Linux-PAM extensions separately.
+        let mut conv = tester.as_conversation();
 
-        assert_eq!(
-            vec![
-                Response::Text("zero".to_owned()),
-                Response::Binary(super::BinaryData::new(vec![5, 5, 5], 5)),
-            ],
-            tester
-                .converse(&[
-                    Message::RadioPrompt("channel?"),
-                    Message::BinaryPrompt {
-                        data: &[10, 9, 8],
-                        data_type: 66
-                    },
-                ])
-                .unwrap()
-        );
+        let radio = RadioPrompt::ask("channel?");
+        let bin = BinaryPrompt::ask(&[10, 9, 8], 66);
+        conv.communicate(&[radio.message(), bin.message()]);
+
+        assert_eq!("zero", radio.answer().unwrap());
+        assert_eq!(BinaryData::new(vec![5, 5, 5], 5), bin.answer().unwrap());
     }
 
-    #[test]
     fn test_mux() {
-        use super::ConversationMux;
         struct MuxTester;
 
         impl Conversation for MuxTester {
-            fn converse(&mut self, messages: &[Message]) -> crate::Result<Vec<Response>> {
+            fn communicate(&mut self, messages: &[Message]) {
                 if let [msg] = messages {
-                    match msg {
-                        Message::Info(info) => {
-                            assert_eq!("let me tell you", *info);
-                            Ok(vec![Response::NoResponse])
+                    match *msg {
+                        Message::InfoMsg(info) => {
+                            assert_eq!("let me tell you", info.question());
+                            info.set_answer(Ok(()))
                         }
-                        Message::Error(error) => {
-                            assert_eq!("oh no", *error);
-                            Ok(vec![Response::NoResponse])
-                        }
-                        Message::Prompt("should_error") => Err(ErrorCode::BufferError),
-                        Message::Prompt(ask) => {
-                            assert_eq!("question", *ask);
-                            Ok(vec![Response::Text("answer".to_owned())])
+                        Message::ErrorMsg(error) => {
+                            assert_eq!("oh no", error.question());
+                            error.set_answer(Ok(()))
                         }
-                        Message::MaskedPrompt("return_wrong_type") => {
-                            Ok(vec![Response::NoResponse])
-                        }
+                        Message::Prompt(prompt) => prompt.set_answer(match prompt.question() {
+                            "should_err" => Err(ErrorCode::PermissionDenied),
+                            "question" => Ok("answer".to_owned()),
+                            other => panic!("unexpected question {other:?}"),
+                        }),
                         Message::MaskedPrompt(ask) => {
-                            assert_eq!("password!", *ask);
-                            Ok(vec![Response::MaskedText(SecureString::from(
-                                "open sesame",
-                            ))])
+                            assert_eq!("password!", ask.question());
+                            ask.set_answer(Ok("open sesame".into()))
                         }
-                        Message::BinaryPrompt { data, data_type } => {
-                            assert_eq!(&[1, 2, 3], data);
-                            assert_eq!(69, *data_type);
-                            Ok(vec![Response::Binary(super::BinaryData::new(
-                                vec![3, 2, 1],
-                                42,
-                            ))])
+                        Message::BinaryPrompt(prompt) => {
+                            assert_eq!(&[1, 2, 3], prompt.question().data);
+                            assert_eq!(69, prompt.question().data_type);
+                            prompt.set_answer(Ok(BinaryData::new(vec![3, 2, 1], 42)))
                         }
                         Message::RadioPrompt(ask) => {
-                            assert_eq!("radio?", *ask);
-                            Ok(vec![Response::Text("yes".to_owned())])
+                            assert_eq!("radio?", ask.question());
+                            ask.set_answer(Ok("yes".to_owned()))
                         }
                     }
                 } else {
-                    panic!("messages is the wrong size ({len})", len = messages.len())
+                    panic!(
+                        "there should only be one message, not {len}",
+                        len = messages.len()
+                    )
                 }
             }
         }
@@ -428,12 +585,12 @@
             SecureString::from("open sesame"),
             tester.masked_prompt("password!").unwrap()
         );
-        tester.error("oh no");
-        tester.info("let me tell you");
+        tester.error_msg("oh no");
+        tester.info_msg("let me tell you");
         {
             assert_eq!("yes", tester.radio_prompt("radio?").unwrap());
             assert_eq!(
-                super::BinaryData::new(vec![3, 2, 1], 42),
+                BinaryData::new(vec![3, 2, 1], 42),
                 tester.binary_prompt(&[1, 2, 3], 69).unwrap(),
             )
         }
--- a/src/lib.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/lib.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -26,17 +26,20 @@
 #![allow(dead_code)]
 
 pub mod constants;
-mod conv;
+pub mod conv;
 pub mod module;
 
 pub mod handle;
+
+#[cfg(feature = "link")]
 mod pam_ffi;
 
+#[cfg(feature = "link")]
+pub use crate::pam_ffi::{LibPamHandle, OwnedLibPamHandle};
 #[doc(inline)]
 pub use crate::{
     constants::{ErrorCode, Flags, Result},
-    conv::{Conversation, ConversationMux, DemuxedConversation, Response},
+    conv::{BinaryData, Conversation, SimpleConversation},
     handle::{PamHandleApplication, PamHandleModule, PamShared},
     module::PamModule,
-    pam_ffi::{LibPamHandle, OwnedLibPamHandle},
 };
--- a/src/module.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/module.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -234,158 +234,3 @@
         Err(ErrorCode::Ignore)
     }
 }
-
-/// Generates the dynamic library entry points for a [PamModule] implementation.
-///
-/// Calling `pam_hooks!(SomeType)` on a type that implements [PamModule] will
-/// generate the exported `extern "C"` functions that PAM uses to call into
-/// your module.
-///
-/// ## Examples:
-///
-/// Here is full example of a PAM module that would authenticate and authorize everybody:
-///
-/// ```no_run
-/// use nonstick::{Flags, OwnedLibPamHandle, PamModule, PamHandleModule, Result as PamResult, pam_hooks};
-/// use std::ffi::CStr;
-/// # fn main() {}
-///
-/// struct MyPamModule;
-/// pam_hooks!(MyPamModule);
-///
-/// impl<T: PamHandleModule> PamModule<T> for MyPamModule {
-///     fn authenticate(handle: &mut T, args: Vec<&CStr>, flags: Flags) -> PamResult<()> {
-///         let password = handle.get_authtok(Some("what's your password?"))?;
-///         // You should use a Conversation to communicate with the user
-///         // instead of writing to the console, but this is just an example.
-///         eprintln!("If you say your password is {password:?}, who am I to disagree?");
-///         Ok(())
-///     }
-///
-///     fn account_management(handle: &mut T, args: Vec<&CStr>, flags: Flags) -> PamResult<()> {
-///         let username = handle.get_user(None)?;
-///         eprintln!("Hello {username:?}! I trust you unconditionally!");
-///         Ok(())
-///     }
-/// }
-/// ```
-#[macro_export]
-macro_rules! pam_hooks {
-    ($ident:ident) => {
-        mod _pam_hooks_scope {
-            use std::ffi::{c_char, c_int, CStr};
-            use $crate::{ErrorCode, Flags, LibPamHandle, PamModule};
-
-            #[no_mangle]
-            extern "C" fn pam_sm_acct_mgmt(
-                pamh: *mut libc::c_void,
-                flags: Flags,
-                argc: c_int,
-                argv: *const *const c_char,
-            ) -> c_int {
-                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
-                    let args = extract_argv(argc, argv);
-                    ErrorCode::result_to_c(super::$ident::account_management(handle, args, flags))
-                } else {
-                    ErrorCode::Ignore as c_int
-                }
-            }
-
-            #[no_mangle]
-            extern "C" fn pam_sm_authenticate(
-                pamh: *mut libc::c_void,
-                flags: Flags,
-                argc: c_int,
-                argv: *const *const c_char,
-            ) -> c_int {
-                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
-                    let args = extract_argv(argc, argv);
-                    ErrorCode::result_to_c(super::$ident::authenticate(handle, args, flags))
-                } else {
-                    ErrorCode::Ignore as c_int
-                }
-            }
-
-            #[no_mangle]
-            extern "C" fn pam_sm_chauthtok(
-                pamh: *mut libc::c_void,
-                flags: Flags,
-                argc: c_int,
-                argv: *const *const c_char,
-            ) -> c_int {
-                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
-                    let args = extract_argv(argc, argv);
-                    ErrorCode::result_to_c(super::$ident::change_authtok(handle, args, flags))
-                } else {
-                    ErrorCode::Ignore as c_int
-                }
-            }
-
-            #[no_mangle]
-            extern "C" fn pam_sm_close_session(
-                pamh: *mut libc::c_void,
-                flags: Flags,
-                argc: c_int,
-                argv: *const *const c_char,
-            ) -> c_int {
-                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
-                    let args = extract_argv(argc, argv);
-                    ErrorCode::result_to_c(super::$ident::close_session(handle, args, flags))
-                } else {
-                    ErrorCode::Ignore as c_int
-                }
-            }
-
-            #[no_mangle]
-            extern "C" fn pam_sm_open_session(
-                pamh: *mut libc::c_void,
-                flags: Flags,
-                argc: c_int,
-                argv: *const *const c_char,
-            ) -> c_int {
-                let args = extract_argv(argc, argv);
-                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
-                    ErrorCode::result_to_c(super::$ident::open_session(handle, args, flags))
-                } else {
-                    ErrorCode::Ignore as c_int
-                }
-            }
-
-            #[no_mangle]
-            extern "C" fn pam_sm_setcred(
-                pamh: *mut libc::c_void,
-                flags: Flags,
-                argc: c_int,
-                argv: *const *const c_char,
-            ) -> c_int {
-                let args = extract_argv(argc, argv);
-                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
-                    ErrorCode::result_to_c(super::$ident::set_credentials(handle, args, flags))
-                } else {
-                    ErrorCode::Ignore as c_int
-                }
-            }
-
-            /// Turns `argc`/`argv` into a [Vec] of [CStr]s.
-            ///
-            /// # Safety
-            ///
-            /// We use this only with arguments we get from `libpam`, which we kind of have to trust.
-            fn extract_argv<'a>(argc: c_int, argv: *const *const c_char) -> Vec<&'a CStr> {
-                (0..argc)
-                    .map(|o| unsafe { CStr::from_ptr(*argv.offset(o as isize) as *const c_char) })
-                    .collect()
-            }
-        }
-    };
-}
-
-#[cfg(test)]
-mod tests {
-    // Compile-time test that the `pam_hooks` macro compiles.
-    use super::super::{PamHandleModule, PamModule};
-    struct Foo;
-    impl<T: PamHandleModule> PamModule<T> for Foo {}
-
-    pam_hooks!(Foo);
-}
--- a/src/pam_ffi/conversation.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/pam_ffi/conversation.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -75,7 +75,7 @@
                 .map(Message::try_from)
                 .collect::<StdResult<_, _>>()
                 .map_err(|_| ErrorCode::ConversationError)?;
-            let responses = conv.converse(&messages)?;
+            let responses = conv.communicate(&messages)?;
             let owned =
                 OwnedResponses::build(&responses).map_err(|_| ErrorCode::ConversationError)?;
             *response_ptr = owned.into_ptr();
@@ -86,7 +86,7 @@
 }
 
 impl Conversation for LibPamConversation<'_> {
-    fn converse(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
+    fn communicate(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
         let mut msgs_to_send = OwnedMessages::alloc(messages.len());
         for (dst, src) in iter::zip(msgs_to_send.iter_mut(), messages.iter()) {
             dst.set(*src).map_err(|_| ErrorCode::ConversationError)?
@@ -149,7 +149,7 @@
             text_resp.free_contents();
             ret
         }
-        Message::Error(_) | Message::Info(_) => Response::NoResponse,
+        Message::ErrorMsg(_) | Message::InfoMsg(_) => Response::NoResponse,
         Message::BinaryPrompt { .. } => {
             let bin_resp = unsafe { RawBinaryResponse::upcast(received) };
             let ret = Response::Binary(bin_resp.to_owned());
--- a/src/pam_ffi/handle.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/pam_ffi/handle.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -144,8 +144,8 @@
 }
 
 impl Conversation for LibPamHandle {
-    fn converse(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
-        self.conversation_item()?.converse(messages)
+    fn communicate(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
+        self.conversation_item()?.communicate(messages)
     }
 }
 
--- a/src/pam_ffi/message.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/pam_ffi/message.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -126,8 +126,8 @@
         Message::MaskedPrompt(text) => alloc(Style::PromptEchoOff, text),
         Message::Prompt(text) => alloc(Style::PromptEchoOn, text),
         Message::RadioPrompt(text) => alloc(Style::RadioType, text),
-        Message::Error(text) => alloc(Style::ErrorMsg, text),
-        Message::Info(text) => alloc(Style::TextInfo, text),
+        Message::ErrorMsg(text) => alloc(Style::ErrorMsg, text),
+        Message::InfoMsg(text) => alloc(Style::TextInfo, text),
         Message::BinaryPrompt { data, data_type } => Ok((
             Style::BinaryPrompt,
             (CBinaryData::alloc(data, data_type)?).cast(),
@@ -289,9 +289,9 @@
             match style {
                 Style::PromptEchoOff => Message::MaskedPrompt(input.string_data()?),
                 Style::PromptEchoOn => Message::Prompt(input.string_data()?),
-                Style::TextInfo => Message::Info(input.string_data()?),
-                Style::ErrorMsg => Message::Error(input.string_data()?),
-                Style::RadioType => Message::Error(input.string_data()?),
+                Style::TextInfo => Message::InfoMsg(input.string_data()?),
+                Style::ErrorMsg => Message::ErrorMsg(input.string_data()?),
+                Style::RadioType => Message::ErrorMsg(input.string_data()?),
                 Style::BinaryPrompt => input.data.cast::<CBinaryData>().as_ref().map_or_else(
                     || Message::BinaryPrompt {
                         data_type: 0,
--- a/src/pam_ffi/mod.rs	Thu Jun 05 03:41:38 2025 -0400
+++ b/src/pam_ffi/mod.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -13,6 +13,7 @@
 mod handle;
 mod memory;
 mod message;
+mod module;
 mod response;
 
 pub use handle::{LibPamHandle, OwnedLibPamHandle};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pam_ffi/module.rs	Fri Jun 06 22:21:17 2025 -0400
@@ -0,0 +1,153 @@
+use std::ffi::CStr;
+
+/// Generates the dynamic library entry points for a [PamModule] implementation.
+///
+/// Calling `pam_hooks!(SomeType)` on a type that implements [PamModule] will
+/// generate the exported `extern "C"` functions that PAM uses to call into
+/// your module.
+///
+/// ## Examples:
+///
+/// Here is full example of a PAM module that would authenticate and authorize everybody:
+///
+/// ```
+/// use nonstick::{Flags, OwnedLibPamHandle, PamModule, PamHandleModule, Result as PamResult, pam_hooks};
+/// use std::ffi::CStr;
+/// # fn main() {}
+///
+/// struct MyPamModule;
+/// pam_hooks!(MyPamModule);
+///
+/// impl<T: PamHandleModule> PamModule<T> for MyPamModule {
+///     fn authenticate(handle: &mut T, args: Vec<&CStr>, flags: Flags) -> PamResult<()> {
+///         let password = handle.get_authtok(Some("what's your password?"))?;
+///         handle.info_msg(fmt!("If you say your password is {password:?}, who am I to disagree?"));
+///     }
+///
+///     fn account_management(handle: &mut T, args: Vec<&CStr>, flags: Flags) -> PamResult<()> {
+///         let username = handle.get_user(None)?;
+///         handle.info_msg(fmt!("Hello {username}! I trust you unconditionally."))
+///         Ok(())
+///     }
+/// }
+/// ```
+#[macro_export]
+macro_rules! pam_hooks {
+    ($ident:ident) => {
+        mod _pam_hooks_scope {
+            use std::ffi::{c_char, c_int, CStr};
+            use $crate::{ErrorCode, Flags, LibPamHandle, PamModule};
+
+            #[no_mangle]
+            extern "C" fn pam_sm_acct_mgmt(
+                pamh: *mut libc::c_void,
+                flags: Flags,
+                argc: c_int,
+                argv: *const *const c_char,
+            ) -> c_int {
+                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
+                    let args = extract_argv(argc, argv);
+                    ErrorCode::result_to_c(super::$ident::account_management(handle, args, flags))
+                } else {
+                    ErrorCode::Ignore as c_int
+                }
+            }
+
+            #[no_mangle]
+            extern "C" fn pam_sm_authenticate(
+                pamh: *mut libc::c_void,
+                flags: Flags,
+                argc: c_int,
+                argv: *const *const c_char,
+            ) -> c_int {
+                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
+                    let args = extract_argv(argc, argv);
+                    ErrorCode::result_to_c(super::$ident::authenticate(handle, args, flags))
+                } else {
+                    ErrorCode::Ignore as c_int
+                }
+            }
+
+            #[no_mangle]
+            extern "C" fn pam_sm_chauthtok(
+                pamh: *mut libc::c_void,
+                flags: Flags,
+                argc: c_int,
+                argv: *const *const c_char,
+            ) -> c_int {
+                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
+                    let args = extract_argv(argc, argv);
+                    ErrorCode::result_to_c(super::$ident::change_authtok(handle, args, flags))
+                } else {
+                    ErrorCode::Ignore as c_int
+                }
+            }
+
+            #[no_mangle]
+            extern "C" fn pam_sm_close_session(
+                pamh: *mut libc::c_void,
+                flags: Flags,
+                argc: c_int,
+                argv: *const *const c_char,
+            ) -> c_int {
+                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
+                    let args = extract_argv(argc, argv);
+                    ErrorCode::result_to_c(super::$ident::close_session(handle, args, flags))
+                } else {
+                    ErrorCode::Ignore as c_int
+                }
+            }
+
+            #[no_mangle]
+            extern "C" fn pam_sm_open_session(
+                pamh: *mut libc::c_void,
+                flags: Flags,
+                argc: c_int,
+                argv: *const *const c_char,
+            ) -> c_int {
+                let args = extract_argv(argc, argv);
+                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
+                    ErrorCode::result_to_c(super::$ident::open_session(handle, args, flags))
+                } else {
+                    ErrorCode::Ignore as c_int
+                }
+            }
+
+            #[no_mangle]
+            extern "C" fn pam_sm_setcred(
+                pamh: *mut libc::c_void,
+                flags: Flags,
+                argc: c_int,
+                argv: *const *const c_char,
+            ) -> c_int {
+                let args = extract_argv(argc, argv);
+                if let Some(handle) = unsafe { pamh.cast::<LibPamHandle>().as_mut() } {
+                    ErrorCode::result_to_c(super::$ident::set_credentials(handle, args, flags))
+                } else {
+                    ErrorCode::Ignore as c_int
+                }
+            }
+
+            /// Turns `argc`/`argv` into a [Vec] of [CStr]s.
+            ///
+            /// # Safety
+            ///
+            /// We use this only with arguments we get from `libpam`, which we kind of have to trust.
+            fn extract_argv<'a>(argc: c_int, argv: *const *const c_char) -> Vec<&'a CStr> {
+                (0..argc)
+                    .map(|o| unsafe { CStr::from_ptr(*argv.offset(o as isize) as *const c_char) })
+                    .collect()
+            }
+        }
+    };
+}
+
+#[cfg(test)]
+mod tests {
+    // Compile-time test that the `pam_hooks` macro compiles.
+    use crate::{PamHandleModule, PamModule};
+    struct Foo;
+    impl<T: PamHandleModule> PamModule<T> for Foo {}
+
+    pam_hooks!(Foo);
+}