changeset 87:05291b601f0a

Well and truly separate the Linux extensions. This separates the Linux extensions on the libpam side, and disables the two enums on the interface side. Users can still call the Linux extensions from non-Linux PAM impls, but they'll get a conversation error back.
author Paul Fisher <paul@pfish.zone>
date Tue, 10 Jun 2025 04:40:01 -0400
parents 23162cd399aa
children c9fc7e6257d3
files src/constants.rs src/conv.rs src/libpam/answer.rs src/libpam/conversation.rs src/libpam/pam_ffi.rs src/libpam/question.rs
diffstat 6 files changed, 301 insertions(+), 219 deletions(-) [+]
line wrap: on
line diff
--- a/src/constants.rs	Tue Jun 10 02:43:31 2025 -0400
+++ b/src/constants.rs	Tue Jun 10 04:40:01 2025 -0400
@@ -1,5 +1,9 @@
 //! Constants and enum values from the PAM library.
 
+// We have a lot of dumb casts that we just gotta do because of differences
+// between Linux-PAM and OpenPAM header files.
+#![allow(clippy::unnecessary_cast)]
+
 #[cfg(feature = "link")]
 use crate::libpam::pam_ffi;
 use bitflags::bitflags;
@@ -198,8 +202,10 @@
     ModuleUnknown = pam_ffi::PAM_MODULE_UNKNOWN,
     #[error("bad item passed to pam_[whatever]_item")]
     BadItem = pam_ffi::PAM_BAD_ITEM,
+    #[cfg(feature = "linux-pam-extensions")]
     #[error("conversation function is event-driven and data is not available yet")]
     ConversationAgain = pam_ffi::PAM_CONV_AGAIN,
+    #[cfg(feature = "linux-pam-extensions")]
     #[error("call this function again to complete authentication stack")]
     Incomplete = pam_ffi::PAM_INCOMPLETE,
 }
--- a/src/conv.rs	Tue Jun 10 02:43:31 2025 -0400
+++ b/src/conv.rs	Tue Jun 10 04:40:01 2025 -0400
@@ -17,10 +17,10 @@
 pub enum Message<'a> {
     Prompt(&'a QAndA<'a>),
     MaskedPrompt(&'a MaskedQAndA<'a>),
+    Error(&'a ErrorMsg<'a>),
+    Info(&'a InfoMsg<'a>),
     RadioPrompt(&'a RadioQAndA<'a>),
     BinaryPrompt(&'a BinaryQAndA<'a>),
-    Error(&'a ErrorMsg<'a>),
-    Info(&'a InfoMsg<'a>),
 }
 
 impl Message<'_> {
@@ -50,24 +50,23 @@
         match *self {
             Message::Prompt(m) => m.set_answer(Err(err)),
             Message::MaskedPrompt(m) => m.set_answer(Err(err)),
+            Message::Error(m) => m.set_answer(Err(err)),
+            Message::Info(m) => m.set_answer(Err(err)),
             Message::RadioPrompt(m) => m.set_answer(Err(err)),
             Message::BinaryPrompt(m) => m.set_answer(Err(err)),
-            Message::Error(m) => m.set_answer(Err(err)),
-            Message::Info(m) => m.set_answer(Err(err)),
         }
     }
 }
 
 macro_rules! q_and_a {
-    ($name:ident<'a, Q=$qt:ty, A=$at:ty>, $val:path, $($doc:literal)*) => {
-        $(
-            #[doc = $doc]
-        )*
+    ($(#[$m:meta])* $name:ident<'a, Q=$qt:ty, A=$at:ty>, $val:path) => {
+        $(#[$m])*
         pub struct $name<'a> {
             q: $qt,
             a: Cell<Result<$at>>,
         }
 
+        $(#[$m])*
         impl<'a> $name<'a> {
             #[doc = concat!("Creates a `", stringify!($t), "` to be sent to the user.")]
             pub fn new(question: $qt) -> Self {
@@ -108,6 +107,7 @@
 
         // shout out to stackoverflow user ballpointben for this lazy impl:
         // https://stackoverflow.com/a/78871280/39808
+        $(#[$m])*
         impl fmt::Debug for $name<'_> {
             fn fmt(&self, f: &mut fmt::Formatter<'_>) -> StdResult<(), fmt::Error> {
                 #[derive(Debug)]
@@ -119,44 +119,44 @@
 }
 
 q_and_a!(
+    /// A Q&A that asks the user for text and does not show it while typing.
+    ///
+    /// In other words, a password entry prompt.
     MaskedQAndA<'a, Q=&'a str, A=SecureString>,
-    Message::MaskedPrompt,
-    "A Q&A that asks the user for text and does not show it while typing."
-    ""
-    "In other words, a password entry prompt."
+    Message::MaskedPrompt
 );
 
 q_and_a!(
+    /// A standard Q&A prompt that asks the user for text.
+    ///
+    /// 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.
     QAndA<'a, Q=&'a str, A=String>,
-    Message::Prompt,
-    "A standard Q&A prompt that asks the user for text."
-    ""
-    "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."
+    Message::Prompt
 );
 
 q_and_a!(
+    /// A Q&A 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.
     RadioQAndA<'a, Q=&'a str, A=String>,
-    Message::RadioPrompt,
-    "A Q&A 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."
+    Message::RadioPrompt
 );
 
 q_and_a!(
+    /// 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.
     BinaryQAndA<'a, Q=(&'a [u8], u8), A=BinaryData>,
-    Message::BinaryPrompt,
-    "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."
+    Message::BinaryPrompt
 );
 
 /// Owned binary data.
@@ -202,23 +202,23 @@
 }
 
 q_and_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).
     InfoMsg<'a, Q = &'a str, A = ()>,
-    Message::Info,
-    "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)."
+    Message::Info
 );
 
 q_and_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).
     ErrorMsg<'a, Q = &'a str, A = ()>,
-    Message::Error,
-    "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)."
+    Message::Error
 );
 
 /// A channel for PAM modules to request information from the user.
@@ -318,10 +318,6 @@
 /// #     todo!()
 /// # }
 /// #
-/// # fn radio_prompt(&mut self, request: &str) -> Result<String> {
-/// #     todo!()
-/// # }
-/// #
 /// # fn error_msg(&mut self, message: &str) {
 /// #     todo!()
 /// # }
@@ -330,6 +326,10 @@
 /// #     todo!()
 /// # }
 /// #
+/// # fn radio_prompt(&mut self, request: &str) -> Result<String> {
+/// #     todo!()
+/// # }
+/// #
 /// # fn binary_prompt(&mut self, (data, data_type): (&[u8], u8)) -> Result<BinaryData> {
 /// #     todo!()
 /// # }
@@ -356,27 +356,45 @@
     fn prompt(&mut self, request: &str) -> Result<String>;
     /// Prompts the user for something, but hides what the user types.
     fn masked_prompt(&mut self, request: &str) -> Result<SecureString>;
-    /// 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>;
     /// Alerts the user to an error.
     fn error_msg(&mut self, message: &str);
     /// Sends an informational message to the user.
     fn info_msg(&mut self, message: &str);
-    /// Requests binary data from the user (a Linux-PAM extension).
-    fn binary_prompt(&mut self, data_and_type: (&[u8], u8)) -> Result<BinaryData>;
+    /// \[Linux extension] Prompts the user for a yes/no/maybe conditional.
+    ///
+    /// PAM documentation doesn't define the format of the response.
+    ///
+    /// When called on an implementation that doesn't support radio prompts,
+    /// this will return [`ErrorCode::ConversationError`].
+    /// If implemented on an implementation that doesn't support radio prompts,
+    /// this will never be called.
+    fn radio_prompt(&mut self, request: &str) -> Result<String> {
+        let _ = request;
+        Err(ErrorCode::ConversationError)
+    }
+    /// \[Linux extension] Requests binary data from the user.
+    ///
+    /// When called on an implementation that doesn't support radio prompts,
+    /// this will return [`ErrorCode::ConversationError`].
+    /// If implemented on an implementation that doesn't support radio prompts,
+    /// this will never be called.
+    fn binary_prompt(&mut self, data_and_type: (&[u8], u8)) -> Result<BinaryData> {
+        let _ = data_and_type;
+        Err(ErrorCode::ConversationError)
+    }
 }
 
 macro_rules! conv_fn {
-    ($fn_name:ident($($param:tt: $pt:ty),+) -> $resp_type:ty { $msg:ty }) => {
+    ($(#[$m:meta])* $fn_name:ident($($param:tt: $pt:ty),+) -> $resp_type:ty { $msg:ty }) => {
+        $(#[$m])*
         fn $fn_name(&mut self, $($param: $pt),*) -> Result<$resp_type> {
             let prompt = <$msg>::new($($param),*);
             self.communicate(&[prompt.message()]);
             prompt.answer()
         }
     };
-    ($fn_name:ident($($param:tt: $pt:ty),+) { $msg:ty }) => {
+    ($(#[$m:meta])*$fn_name:ident($($param:tt: $pt:ty),+) { $msg:ty }) => {
+        $(#[$m])*
         fn $fn_name(&mut self, $($param: $pt),*) {
             self.communicate(&[<$msg>::new($($param),*).message()]);
         }
@@ -386,9 +404,9 @@
 impl<C: Conversation> SimpleConversation for C {
     conv_fn!(prompt(message: &str) -> String { QAndA });
     conv_fn!(masked_prompt(message: &str) -> SecureString { MaskedQAndA } );
-    conv_fn!(radio_prompt(message: &str) -> String { RadioQAndA });
     conv_fn!(error_msg(message: &str) { ErrorMsg });
     conv_fn!(info_msg(message: &str) { InfoMsg });
+    conv_fn!(radio_prompt(message: &str) -> String { RadioQAndA });
     conv_fn!(binary_prompt((data, data_type): (&[u8], u8)) -> BinaryData { BinaryQAndA });
 }
 
@@ -427,12 +445,8 @@
 
 #[cfg(test)]
 mod tests {
-    use super::{
-        BinaryQAndA, Conversation, ErrorMsg, InfoMsg, MaskedQAndA, Message, QAndA, RadioQAndA,
-        Result, SecureString, SimpleConversation,
-    };
+    use super::*;
     use crate::constants::ErrorCode;
-    use crate::BinaryData;
 
     #[test]
     fn test_demux() {
@@ -454,10 +468,6 @@
                 assert_eq!("reveal", request);
                 Ok(SecureString::from("my secrets"))
             }
-            fn radio_prompt(&mut self, request: &str) -> Result<String> {
-                assert_eq!("channel?", request);
-                Ok("zero".to_owned())
-            }
             fn error_msg(&mut self, message: &str) {
                 self.error_ran = true;
                 assert_eq!("whoopsie", message);
@@ -466,6 +476,10 @@
                 self.info_ran = true;
                 assert_eq!("did you know", message);
             }
+            fn radio_prompt(&mut self, request: &str) -> Result<String> {
+                assert_eq!("channel?", request);
+                Ok("zero".to_owned())
+            }
             fn binary_prompt(&mut self, data_and_type: (&[u8], u8)) -> Result<BinaryData> {
                 assert_eq!((&[10, 9, 8][..], 66), data_and_type);
                 Ok(BinaryData::new(vec![5, 5, 5], 5))
@@ -501,15 +515,16 @@
         assert!(tester.info_ran);
 
         // Test the Linux extensions separately.
-
-        let mut conv = tester.as_conversation();
+        {
+            let mut conv = tester.as_conversation();
 
-        let radio = RadioQAndA::new("channel?");
-        let bin = BinaryQAndA::new((&[10, 9, 8], 66));
-        conv.communicate(&[radio.message(), bin.message()]);
+            let radio = RadioQAndA::new("channel?");
+            let bin = BinaryQAndA::new((&[10, 9, 8], 66));
+            conv.communicate(&[radio.message(), bin.message()]);
 
-        assert_eq!("zero", radio.answer().unwrap());
-        assert_eq!(BinaryData::from(([5, 5, 5], 5)), bin.answer().unwrap());
+            assert_eq!("zero", radio.answer().unwrap());
+            assert_eq!(BinaryData::from(([5, 5, 5], 5)), bin.answer().unwrap());
+        }
     }
 
     fn test_mux() {
@@ -563,6 +578,7 @@
         );
         tester.error_msg("oh no");
         tester.info_msg("let me tell you");
+        // Linux-PAM extensions. Always implemented, but separate for clarity.
         {
             assert_eq!("yes", tester.radio_prompt("radio?").unwrap());
             assert_eq!(
--- a/src/libpam/answer.rs	Tue Jun 10 02:43:31 2025 -0400
+++ b/src/libpam/answer.rs	Tue Jun 10 04:40:01 2025 -0400
@@ -29,10 +29,12 @@
             match input {
                 OwnedMessage::MaskedPrompt(p) => TextAnswer::fill(output, p.answer()?.unsecure())?,
                 OwnedMessage::Prompt(p) => TextAnswer::fill(output, &(p.answer()?))?,
-                OwnedMessage::BinaryPrompt(p) => BinaryAnswer::fill(output, (&p.answer()?).into())?,
                 OwnedMessage::Error(p) => TextAnswer::fill(output, p.answer().map(|_| "")?)?,
                 OwnedMessage::Info(p) => TextAnswer::fill(output, p.answer().map(|_| "")?)?,
+                // If we're here, that means that we *got* a Linux-PAM
+                // question from PAM, so we're OK to answer it.
                 OwnedMessage::RadioPrompt(p) => TextAnswer::fill(output, &(p.answer()?))?,
+                OwnedMessage::BinaryPrompt(p) => BinaryAnswer::fill(output, (&p.answer()?).into())?,
             }
         }
         Ok(outputs)
@@ -213,66 +215,75 @@
 
 #[cfg(test)]
 mod tests {
-    use super::{Answer, Answers, BinaryAnswer, TextAnswer};
-    use crate::conv::{BinaryQAndA, ErrorMsg, InfoMsg, MaskedQAndA, QAndA, RadioQAndA};
-    use crate::libpam::conversation::OwnedMessage;
-    use crate::libpam::memory;
-    use crate::BinaryData;
+    use super::*;
+    use crate::conv::{ErrorMsg, InfoMsg, MaskedQAndA, QAndA};
+
+    macro_rules! answered {
+        ($typ:ty, $msg:path, $data:expr) => {{
+            let qa = <$typ>::new("");
+            qa.set_answer(Ok($data));
+            $msg(qa)
+        }};
+    }
+
+    fn assert_text_answer(want: &str, answer: &mut Answer) {
+        let up = unsafe { TextAnswer::upcast(answer) };
+        assert_eq!(want, up.contents().unwrap());
+        up.free_contents();
+        assert_eq!("", up.contents().unwrap());
+    }
+
+    fn round_trip(msgs: Vec<OwnedMessage>) -> Answers {
+        let n = msgs.len();
+        let sent = Answers::build(msgs).unwrap();
+        unsafe { Answers::from_c_heap(sent.into_ptr(), n) }
+    }
 
     #[test]
     fn test_round_trip() {
+        let mut answers = round_trip(vec![
+            answered!(QAndA, OwnedMessage::Prompt, "whats going on".to_owned()),
+            answered!(MaskedQAndA, OwnedMessage::MaskedPrompt, "well then".into()),
+            answered!(ErrorMsg, OwnedMessage::Error, ()),
+            answered!(InfoMsg, OwnedMessage::Info, ()),
+        ]);
+
+        if let [going, well, err, info] = &mut answers[..] {
+            assert_text_answer("whats going on", going);
+            assert_text_answer("well then", well);
+            assert_text_answer("", err);
+            assert_text_answer("", info);
+        } else {
+            panic!("received wrong size {len}!", len = answers.len())
+        }
+    }
+
+    #[cfg(feature = "linux-pam-extensions")]
+    fn test_round_trip_linux() {
+        use crate::conv::{BinaryData, BinaryQAndA, RadioQAndA};
         let binary_msg = {
             let qa = BinaryQAndA::new((&[][..], 0));
             qa.set_answer(Ok(BinaryData::new(vec![1, 2, 3], 99)));
             OwnedMessage::BinaryPrompt(qa)
         };
-
-        macro_rules! answered {
-            ($typ:ty, $msg:path, $data:expr) => {{
-                let qa = <$typ>::new("");
-                qa.set_answer(Ok($data));
-                $msg(qa)
-            }};
-        }
-
-        let answers = vec![
+        let mut answers = round_trip(vec![
             binary_msg,
-            answered!(QAndA, OwnedMessage::Prompt, "whats going on".to_owned()),
-            answered!(MaskedQAndA, OwnedMessage::MaskedPrompt, "well then".into()),
-            answered!(ErrorMsg, OwnedMessage::Error, ()),
-            answered!(InfoMsg, OwnedMessage::Info, ()),
             answered!(
                 RadioQAndA,
                 OwnedMessage::RadioPrompt,
                 "beep boop".to_owned()
             ),
-        ];
-        let n = answers.len();
-        let sent = Answers::build(answers).unwrap();
-        let heap_answers = sent.into_ptr();
-        let mut received = unsafe { Answers::from_c_heap(heap_answers, n) };
+        ]);
 
-        let assert_text = |want, raw| {
-            let up = unsafe { TextAnswer::upcast(raw) };
-            assert_eq!(want, up.contents().unwrap());
-            up.free_contents();
-            assert_eq!("", up.contents().unwrap());
-        };
-        let assert_bin = |want, raw| {
-            let up = unsafe { BinaryAnswer::upcast(raw) };
-            assert_eq!(BinaryData::from(want), up.data().into());
+        if let [bin, radio] = &mut answers[..] {
+            let up = unsafe { BinaryAnswer::upcast(bin) };
+            assert_eq!(BinaryData::from((&[1, 2, 3][..], 99)), up.data().into());
             up.zero_contents();
             assert_eq!(BinaryData::default(), up.data().into());
-        };
-        if let [zero, one, two, three, four, five] = &mut received[..] {
-            assert_bin((&[1, 2, 3][..], 99), zero);
-            assert_text("whats going on", one);
-            assert_text("well then", two);
-            assert_text("", three);
-            assert_text("", four);
-            assert_text("beep boop", five);
+
+            assert_text_answer("beep boop", radio);
         } else {
-            panic!("received wrong size {len}!", len = received.len())
+            panic!("received wrong size {len}!", len = answers.len())
         }
     }
 
@@ -292,6 +303,7 @@
 
     #[test]
     fn test_binary_answer() {
+        use crate::conv::BinaryData;
         let answer_ptr: *mut Answer = memory::calloc(1);
         let answer = unsafe { &mut *answer_ptr };
         let real_data = BinaryData::new([1, 2, 3, 4, 5, 6, 7, 8], 9);
@@ -306,7 +318,7 @@
     #[test]
     #[ignore]
     fn test_binary_answer_too_big() {
-        let big_data: Vec<u8> = vec![0xFFu8; 10_000_000_000];
+        let big_data: Vec<u8> = vec![0xFFu8; 0x1_0000_0001];
         let answer_ptr: *mut Answer = memory::calloc(1);
         let answer = unsafe { &mut *answer_ptr };
         BinaryAnswer::fill(answer, (&big_data, 100)).expect_err("this is too big!");
--- a/src/libpam/conversation.rs	Tue Jun 10 02:43:31 2025 -0400
+++ b/src/libpam/conversation.rs	Tue Jun 10 04:40:01 2025 -0400
@@ -1,7 +1,7 @@
-use crate::conv::{
-    BinaryQAndA, Conversation, ErrorMsg, InfoMsg, MaskedQAndA, Message, QAndA, RadioQAndA,
-};
-use crate::libpam::answer::{Answer, Answers, BinaryAnswer, TextAnswer};
+use crate::conv::{BinaryQAndA, RadioQAndA};
+use crate::conv::{Conversation, ErrorMsg, InfoMsg, MaskedQAndA, Message, QAndA};
+use crate::libpam::answer::BinaryAnswer;
+use crate::libpam::answer::{Answer, Answers, TextAnswer};
 use crate::libpam::memory::Immovable;
 use crate::libpam::pam_ffi::AppData;
 pub use crate::libpam::pam_ffi::LibPamConversation;
@@ -11,6 +11,7 @@
 use std::ffi::c_int;
 use std::iter;
 use std::marker::PhantomData;
+use std::result::Result as StdResult;
 
 impl LibPamConversation<'_> {
     fn wrap<C: Conversation>(conv: &mut C) -> Self {
@@ -22,6 +23,9 @@
         }
     }
 
+    /// Passed as the conversation function into PAM for an owned handle.
+    ///
+    /// PAM calls this, we compute answers, then send them back.
     unsafe extern "C" fn wrapper_callback<C: Conversation>(
         count: c_int,
         questions: *const *const Question,
@@ -43,11 +47,13 @@
                 .map(TryInto::try_into)
                 .collect::<Result<_>>()
                 .map_err(|_| ErrorCode::ConversationError)?;
-            // Borrow all those Q&As and ask them
-            let borrowed: Vec<Message> = messages.iter().map(Into::into).collect();
-            conv.communicate(&borrowed);
+            // Borrow all those Q&As and ask them.
+            // If we got an invalid message type, bail before sending.
+            let borrowed: Result<Vec<_>> = messages.iter().map(Message::try_from).collect();
+            // TODO: Do we want to log something here?
+            conv.communicate(&borrowed?);
 
-            // Send our answers back
+            // Send our answers back.
             let owned = Answers::build(messages).map_err(|_| ErrorCode::ConversationError)?;
             *answers_ptr = owned.into_ptr();
             Ok(())
@@ -93,21 +99,22 @@
 pub enum OwnedMessage<'a> {
     MaskedPrompt(MaskedQAndA<'a>),
     Prompt(QAndA<'a>),
+    Info(InfoMsg<'a>),
+    Error(ErrorMsg<'a>),
     RadioPrompt(RadioQAndA<'a>),
     BinaryPrompt(BinaryQAndA<'a>),
-    Info(InfoMsg<'a>),
-    Error(ErrorMsg<'a>),
 }
 
-impl<'a> From<&'a OwnedMessage<'a>> for Message<'a> {
-    fn from(src: &'a OwnedMessage) -> Self {
+impl<'a> TryFrom<&'a OwnedMessage<'a>> for Message<'a> {
+    type Error = ErrorCode;
+    fn try_from(src: &'a OwnedMessage) -> StdResult<Self, ErrorCode> {
         match src {
-            OwnedMessage::MaskedPrompt(m) => Message::MaskedPrompt(m),
-            OwnedMessage::Prompt(m) => Message::Prompt(m),
-            OwnedMessage::RadioPrompt(m) => Message::RadioPrompt(m),
-            OwnedMessage::BinaryPrompt(m) => Message::BinaryPrompt(m),
-            OwnedMessage::Info(m) => Message::Info(m),
-            OwnedMessage::Error(m) => Message::Error(m),
+            OwnedMessage::MaskedPrompt(m) => Ok(Message::MaskedPrompt(m)),
+            OwnedMessage::Prompt(m) => Ok(Message::Prompt(m)),
+            OwnedMessage::Info(m) => Ok(Message::Info(m)),
+            OwnedMessage::Error(m) => Ok(Message::Error(m)),
+            OwnedMessage::RadioPrompt(m) => Ok(Message::RadioPrompt(m)),
+            OwnedMessage::BinaryPrompt(m) => Ok(Message::BinaryPrompt(m)),
         }
     }
 }
@@ -127,9 +134,9 @@
     match *msg {
         Message::MaskedPrompt(qa) => fill_text!(qa, resp),
         Message::Prompt(qa) => fill_text!(qa, resp),
-        Message::RadioPrompt(qa) => fill_text!(qa, resp),
         Message::Error(m) => m.set_answer(Ok(())),
         Message::Info(m) => m.set_answer(Ok(())),
+        Message::RadioPrompt(qa) => fill_text!(qa, resp),
         Message::BinaryPrompt(qa) => {
             let bin_resp = unsafe { BinaryAnswer::upcast(resp) };
             qa.set_answer(Ok(bin_resp.data().into()));
--- a/src/libpam/pam_ffi.rs	Tue Jun 10 02:43:31 2025 -0400
+++ b/src/libpam/pam_ffi.rs	Tue Jun 10 04:40:01 2025 -0400
@@ -3,8 +3,7 @@
 #![allow(non_camel_case_types)]
 
 use crate::libpam::memory::Immovable;
-use num_enum::{IntoPrimitive, TryFromPrimitive};
-use std::ffi::{c_int, c_void};
+use std::ffi::{c_int, c_uint, c_void};
 use std::marker::PhantomData;
 
 /// An opaque structure that a PAM handle points to.
@@ -38,27 +37,6 @@
     _marker: Immovable,
 }
 
-/// The C enum values for messages shown to the user.
-#[derive(Debug, PartialEq, TryFromPrimitive, IntoPrimitive)]
-#[repr(i32)]
-pub enum Style {
-    /// Requests information from the user; will be masked when typing.
-    PromptEchoOff = 1,
-    /// Requests information from the user; will not be masked.
-    PromptEchoOn = 2,
-    /// An error message.
-    ErrorMsg = 3,
-    /// An informational message.
-    TextInfo = 4,
-    /// Yes/No/Maybe conditionals. A Linux-PAM extension.
-    RadioType = 5,
-    /// For server–client non-human interaction.
-    ///
-    /// NOT part of the X/Open PAM specification.
-    /// A Linux-PAM extension.
-    BinaryPrompt = 7,
-}
-
 /// A question sent by PAM or a module to an application.
 ///
 /// PAM refers to this as a "message", but we call it a question
@@ -69,7 +47,7 @@
 #[repr(C)]
 pub struct Question {
     /// The style of message to request.
-    pub style: c_int,
+    pub style: c_uint,
     /// A description of the data requested.
     ///
     /// For most requests, this will be an owned [`CStr`](std::ffi::CStr), but for requests
--- a/src/libpam/question.rs	Tue Jun 10 02:43:31 2025 -0400
+++ b/src/libpam/question.rs	Tue Jun 10 04:40:01 2025 -0400
@@ -1,12 +1,15 @@
 //! Data and types dealing with PAM messages.
 
-use crate::conv::{BinaryQAndA, ErrorMsg, InfoMsg, MaskedQAndA, Message, QAndA, RadioQAndA};
+#[cfg(feature = "linux-pam-extensions")]
+use crate::conv::{BinaryQAndA, RadioQAndA};
+use crate::conv::{ErrorMsg, InfoMsg, MaskedQAndA, Message, QAndA};
 use crate::libpam::conversation::OwnedMessage;
-use crate::libpam::memory;
 use crate::libpam::memory::{CBinaryData, Immovable};
-pub use crate::libpam::pam_ffi::{Question, Style};
+pub use crate::libpam::pam_ffi::Question;
+use crate::libpam::{memory, pam_ffi};
 use crate::ErrorCode;
 use crate::Result;
+use num_enum::{IntoPrimitive, TryFromPrimitive};
 use std::ffi::{c_void, CStr};
 use std::{iter, ptr, slice};
 
@@ -54,6 +57,7 @@
 ///                                                  ╟──────────────╢
 ///                                                  ║ ...          ║
 /// ```
+#[derive(Debug)]
 pub struct GenericQuestions<I: IndirectTrait> {
     /// An indirection to the questions themselves, stored on the C heap.
     indirect: *mut I,
@@ -71,7 +75,7 @@
         };
         // Even if we fail partway through this, all our memory will be freed.
         for (question, message) in iter::zip(ret.iter_mut(), messages) {
-            question.fill(message)?
+            question.try_fill(message)?
         }
         Ok(ret)
     }
@@ -158,12 +162,13 @@
 /// This is kept separate to provide a place where we can separate
 /// the pointer-to-pointer-to-list from pointer-to-list-of-pointers.
 #[cfg(not(pam_impl = "linux-pam"))]
-pub type Indirect = XSsoIndirect;
+pub type Indirect = StandardIndirect;
 
 pub type Questions = GenericQuestions<Indirect>;
 
 /// The XSSO standard version of the indirection layer between Question and Questions.
-#[repr(transparent)]
+#[derive(Debug)]
+#[repr(C)]
 pub struct StandardIndirect {
     base: *mut Question,
     _marker: Immovable,
@@ -200,7 +205,8 @@
 }
 
 /// The Linux version of the indirection layer between Question and Questions.
-#[repr(transparent)]
+#[derive(Debug)]
+#[repr(C)]
 pub struct LinuxPamIndirect {
     base: [*mut Question; 0],
     _marker: Immovable,
@@ -241,6 +247,29 @@
     }
 }
 
+/// The C enum values for messages shown to the user.
+#[derive(Debug, PartialEq, TryFromPrimitive, IntoPrimitive)]
+#[repr(u32)]
+pub enum Style {
+    /// Requests information from the user; will be masked when typing.
+    PromptEchoOff = pam_ffi::PAM_PROMPT_ECHO_OFF,
+    /// Requests information from the user; will not be masked.
+    PromptEchoOn = pam_ffi::PAM_PROMPT_ECHO_ON,
+    /// An error message.
+    ErrorMsg = pam_ffi::PAM_ERROR_MSG,
+    /// An informational message.
+    TextInfo = pam_ffi::PAM_TEXT_INFO,
+    /// Yes/No/Maybe conditionals. A Linux-PAM extension.
+    #[cfg(feature = "linux-pam-extensions")]
+    RadioType = pam_ffi::PAM_RADIO_TYPE,
+    /// For server–client non-human interaction.
+    ///
+    /// NOT part of the X/Open PAM specification.
+    /// A Linux-PAM extension.
+    #[cfg(feature = "linux-pam-extensions")]
+    BinaryPrompt = pam_ffi::PAM_BINARY_PROMPT,
+}
+
 impl Default for Question {
     fn default() -> Self {
         Self {
@@ -254,8 +283,28 @@
 impl Question {
     /// Replaces the contents of this question with the question
     /// from the message.
-    pub fn fill(&mut self, msg: &Message) -> Result<()> {
-        let (style, data) = copy_to_heap(msg)?;
+    ///
+    /// If the message is not valid (invalid message type, bad contents, etc.),
+    /// this will fail.
+    pub fn try_fill(&mut self, msg: &Message) -> Result<()> {
+        let alloc = |style, text| Ok((style, memory::malloc_str(text)?.cast()));
+        // We will only allocate heap data if we have a valid input.
+        let (style, data): (_, *mut c_void) = match *msg {
+            Message::MaskedPrompt(p) => alloc(Style::PromptEchoOff, p.question()),
+            Message::Prompt(p) => alloc(Style::PromptEchoOn, p.question()),
+            Message::Error(p) => alloc(Style::ErrorMsg, p.question()),
+            Message::Info(p) => alloc(Style::TextInfo, p.question()),
+            #[cfg(feature = "linux-pam-extensions")]
+            Message::RadioPrompt(p) => alloc(Style::RadioType, p.question()),
+            #[cfg(feature = "linux-pam-extensions")]
+            Message::BinaryPrompt(p) => Ok((
+                Style::BinaryPrompt,
+                CBinaryData::alloc(p.question())?.cast(),
+            )),
+            #[cfg(not(feature = "linux-pam-extensions"))]
+            Message::RadioPrompt(_) | Message::BinaryPrompt(_) => Err(ErrorCode::ConversationError),
+        }?;
+        // Now that we know everything is valid, fill ourselves in.
         self.clear();
         self.style = style.into();
         self.data = data;
@@ -291,15 +340,19 @@
         // SAFETY: We either created this data or we got it from PAM.
         // After this function is done, it will be zeroed out.
         unsafe {
+            // This is nice-to-have. We'll try to zero out the data
+            // in the Question. If it's not a supported format, we skip it.
             if let Ok(style) = Style::try_from(self.style) {
                 match style {
+                    #[cfg(feature = "linux-pam-extensions")]
                     Style::BinaryPrompt => {
                         if let Some(d) = self.data.cast::<CBinaryData>().as_mut() {
                             d.zero_contents()
                         }
                     }
+                    #[cfg(feature = "linux-pam-extensions")]
+                    Style::RadioType => memory::zero_c_string(self.data.cast()),
                     Style::TextInfo
-                    | Style::RadioType
                     | Style::ErrorMsg
                     | Style::PromptEchoOff
                     | Style::PromptEchoOn => memory::zero_c_string(self.data.cast()),
@@ -318,7 +371,8 @@
             .style
             .try_into()
             .map_err(|_| ErrorCode::ConversationError)?;
-        // SAFETY: In all cases below, we're matching the
+        // SAFETY: In all cases below, we're creating questions based on
+        // known types that we get from PAM and the inner types it should have.
         let prompt = unsafe {
             match style {
                 Style::PromptEchoOff => {
@@ -327,7 +381,9 @@
                 Style::PromptEchoOn => Self::Prompt(QAndA::new(question.string_data()?)),
                 Style::ErrorMsg => Self::Error(ErrorMsg::new(question.string_data()?)),
                 Style::TextInfo => Self::Info(InfoMsg::new(question.string_data()?)),
+                #[cfg(feature = "linux-pam-extensions")]
                 Style::RadioType => Self::RadioPrompt(RadioQAndA::new(question.string_data()?)),
+                #[cfg(feature = "linux-pam-extensions")]
                 Style::BinaryPrompt => Self::BinaryPrompt(BinaryQAndA::new(question.binary_data())),
             }
         };
@@ -335,30 +391,9 @@
     }
 }
 
-/// Copies the contents of this message to the C heap.
-fn copy_to_heap(msg: &Message) -> Result<(Style, *mut c_void)> {
-    let alloc = |style, text| Ok((style, memory::malloc_str(text)?.cast()));
-    match *msg {
-        Message::MaskedPrompt(p) => alloc(Style::PromptEchoOff, p.question()),
-        Message::Prompt(p) => alloc(Style::PromptEchoOn, p.question()),
-        Message::RadioPrompt(p) => alloc(Style::RadioType, p.question()),
-        Message::Error(p) => alloc(Style::ErrorMsg, p.question()),
-        Message::Info(p) => alloc(Style::TextInfo, p.question()),
-        Message::BinaryPrompt(p) => Ok((
-            Style::BinaryPrompt,
-            CBinaryData::alloc(p.question())?.cast(),
-        )),
-    }
-}
-
 #[cfg(test)]
 mod tests {
 
-    use super::{
-        BinaryQAndA, ErrorMsg, GenericQuestions, IndirectTrait, InfoMsg, LinuxPamIndirect,
-        MaskedQAndA, OwnedMessage, QAndA, RadioQAndA, Result, StandardIndirect,
-    };
-
     macro_rules! assert_matches {
         ($id:ident => $variant:path, $q:expr) => {
             if let $variant($id) = $id {
@@ -370,33 +405,61 @@
     }
 
     macro_rules! tests { ($fn_name:ident<$typ:ident>) => {
-        #[test]
-        fn $fn_name() {
-            let interrogation = GenericQuestions::<$typ>::new(&[
-                MaskedQAndA::new("hocus pocus").message(),
-                BinaryQAndA::new((&[5, 4, 3, 2, 1], 66)).message(),
-                QAndA::new("what").message(),
-                QAndA::new("who").message(),
-                InfoMsg::new("hey").message(),
-                ErrorMsg::new("gasp").message(),
-                RadioQAndA::new("you must choose").message(),
-            ])
-            .unwrap();
-            let indirect = interrogation.indirect();
+        mod $fn_name {
+            use super::super::*;
+            #[test]
+            fn standard() {
+                let interrogation = GenericQuestions::<$typ>::new(&[
+                    MaskedQAndA::new("hocus pocus").message(),
+                    QAndA::new("what").message(),
+                    QAndA::new("who").message(),
+                    InfoMsg::new("hey").message(),
+                    ErrorMsg::new("gasp").message(),
+                ])
+                .unwrap();
+                let indirect = interrogation.indirect();
+
+                let remade = unsafe { $typ::borrow_ptr(indirect) }.unwrap();
+                let messages: Vec<OwnedMessage> = unsafe { remade.iter(interrogation.count) }
+                    .map(TryInto::try_into)
+                    .collect::<Result<_>>()
+                    .unwrap();
+                let [masked, what, who, hey, gasp] = messages.try_into().unwrap();
+                assert_matches!(masked => OwnedMessage::MaskedPrompt, "hocus pocus");
+                assert_matches!(what => OwnedMessage::Prompt, "what");
+                assert_matches!(who => OwnedMessage::Prompt, "who");
+                assert_matches!(hey => OwnedMessage::Info, "hey");
+                assert_matches!(gasp => OwnedMessage::Error, "gasp");
+            }
 
-            let remade = unsafe { $typ::borrow_ptr(indirect) }.unwrap();
-            let messages: Vec<OwnedMessage> = unsafe { remade.iter(interrogation.count) }
-                .map(TryInto::try_into)
-                .collect::<Result<_>>()
-                .unwrap();
-            let [masked, bin, what, who, hey, gasp, choose] = messages.try_into().unwrap();
-            assert_matches!(masked => OwnedMessage::MaskedPrompt, "hocus pocus");
-            assert_matches!(bin => OwnedMessage::BinaryPrompt, (&[5, 4, 3, 2, 1][..], 66));
-            assert_matches!(what => OwnedMessage::Prompt, "what");
-            assert_matches!(who => OwnedMessage::Prompt, "who");
-            assert_matches!(hey => OwnedMessage::Info, "hey");
-            assert_matches!(gasp => OwnedMessage::Error, "gasp");
-            assert_matches!(choose => OwnedMessage::RadioPrompt, "you must choose");
+            #[test]
+            #[cfg(not(feature = "linux-pam-extensions"))]
+            fn no_linux_extensions() {
+                use crate::conv::{BinaryQAndA, RadioQAndA};
+                GenericQuestions::<$typ>::new(&[
+                    BinaryQAndA::new((&[5, 4, 3, 2, 1], 66)).message(),
+                    RadioQAndA::new("you must choose").message(),
+                ]).unwrap_err();
+            }
+
+            #[test]
+            #[cfg(feature = "linux-pam-extensions")]
+            fn linux_extensions() {
+                let interrogation = GenericQuestions::<$typ>::new(&[
+                    BinaryQAndA::new((&[5, 4, 3, 2, 1], 66)).message(),
+                    RadioQAndA::new("you must choose").message(),
+                ]).unwrap();
+                let indirect = interrogation.indirect();
+
+                let remade = unsafe { $typ::borrow_ptr(indirect) }.unwrap();
+                let messages: Vec<OwnedMessage> = unsafe { remade.iter(interrogation.count) }
+                    .map(TryInto::try_into)
+                    .collect::<Result<_>>()
+                    .unwrap();
+                let [bin, choose] = messages.try_into().unwrap();
+                assert_matches!(bin => OwnedMessage::BinaryPrompt, (&[5, 4, 3, 2, 1][..], 66));
+                assert_matches!(choose => OwnedMessage::RadioPrompt, "you must choose");
+            }
         }
     }}