diff src/conv.rs @ 70:9f8381a1c09c

Implement low-level conversation primitives. This change does two primary things: 1. Introduces new Conversation traits, to be implemented both by the library and by PAM client applications. 2. Builds the memory-management infrastructure for passing messages through the conversation. ...and it adds tests for both of the above, including ASAN tests.
author Paul Fisher <paul@pfish.zone>
date Tue, 03 Jun 2025 01:21:59 -0400
parents 8f3ae0c7ab92
children 58f9d2a4df38
line wrap: on
line diff
--- a/src/conv.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/conv.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -1,9 +1,156 @@
 //! The PAM conversation and associated Stuff.
 
-use crate::pam_ffi::{BinaryResponseInner, NulError, TextResponseInner};
-use std::num::TryFromIntError;
-use std::ops::Deref;
+// Temporarily allowed until we get the actual conversation functions hooked up.
+#![allow(dead_code)]
+
+use crate::constants::{NulError, Result, TooBigError};
+use crate::pam_ffi::{BinaryResponseInner, GenericResponse, TextResponseInner};
+use secure_string::SecureString;
+use std::mem;
 use std::result::Result as StdResult;
+use std::str::Utf8Error;
+
+// 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.
+
+/// 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(Debug)]
+pub enum Message<'a> {
+    /// Requests information from the user; will be masked when typing.
+    ///
+    /// Response: [`Response::MaskedText`]
+    MaskedPrompt(&'a str),
+    /// Requests information from the user; will not be masked.
+    ///
+    /// Response: [`Response::Text`]
+    Prompt(&'a str),
+    /// "Yes/No/Maybe conditionals" (a Linux-PAM extension).
+    ///
+    /// Response: [`Response::Text`]
+    /// (Linux-PAM documentation doesn't define its contents.)
+    RadioPrompt(&'a str),
+    /// Raises an error message to the user.
+    ///
+    /// Response: [`Response::NoResponse`]
+    Error(&'a str),
+    /// Sends an informational message to the user.
+    ///
+    /// Response: [`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: [`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 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:
+    ///
+    /// - [`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),
+}
+
+/// A channel for PAM modules to request information from the user.
+///
+/// This trait is used by both applications and PAM modules:
+///
+/// - Applications implement Conversation and provide a user interface
+///   to allow the user to respond to PAM questions.
+/// - Modules call a Conversation implementation to request information
+///   or send information to the user.
+pub trait Conversation {
+    /// Sends messages to the user.
+    ///
+    /// The returned Vec of messages always contains exactly as many entries
+    /// as there were messages in the request; one corresponding to each.
+    ///
+    /// Messages with no response (e.g. [info](Message::Info) and
+    /// [error](Message::Error)) will have a `None` entry instead of a `Response`.
+    fn send(&mut self, messages: &[Message]) -> Result<Vec<Response>>;
+}
+
+/// 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>;
+}
+
+impl<D: DemuxedConversation> Conversation for D {
+    fn send(&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)
+                }
+                Message::Error(message) => {
+                    self.error(message);
+                    Ok(Response::NoResponse)
+                }
+                Message::BinaryPrompt { data_type, data } => {
+                    self.binary_prompt(data, data_type).map(Response::from)
+                }
+            })
+            .collect()
+    }
+}
 
 /// An owned text response to a PAM conversation.
 ///
@@ -12,17 +159,24 @@
 struct TextResponse(*mut TextResponseInner);
 
 impl TextResponse {
-    /// Creates a text response.
+    /// Allocates a new response with the given text.
+    ///
+    /// A copy of the provided text will be allocated on the C heap.
     pub fn new(text: impl AsRef<str>) -> StdResult<Self, NulError> {
         TextResponseInner::alloc(text).map(Self)
     }
-}
 
-impl Deref for TextResponse {
-    type Target = TextResponseInner;
-    fn deref(&self) -> &Self::Target {
-        // SAFETY: We allocated this ourselves, or it was provided by PAM.
-        unsafe { &*self.0 }
+    /// Converts this into a GenericResponse.
+    fn generic(self) -> *mut GenericResponse {
+        let ret = self.0 as *mut GenericResponse;
+        mem::forget(self);
+        ret
+    }
+
+    /// Gets the string data, if possible.
+    pub fn as_str(&self) -> StdResult<&str, Utf8Error> {
+        // SAFETY: We allocated this ourselves or got it back from PAM.
+        unsafe { &*self.0 }.contents().to_str()
     }
 }
 
@@ -38,20 +192,33 @@
 ///
 /// It points to a value on the C heap.
 #[repr(C)]
-struct BinaryResponse(*mut BinaryResponseInner);
+pub struct BinaryResponse(pub(super) *mut BinaryResponseInner);
 
 impl BinaryResponse {
     /// Creates a binary response with the given data.
-    pub fn new(data: impl AsRef<[u8]>, data_type: u8) -> StdResult<Self, TryFromIntError> {
+    ///
+    /// A copy of the data will be made and allocated on the C heap.
+    pub fn new(data: &[u8], data_type: u8) -> StdResult<Self, TooBigError> {
         BinaryResponseInner::alloc(data, data_type).map(Self)
     }
-}
+
+    /// Converts this into a GenericResponse.
+    fn generic(self) -> *mut GenericResponse {
+        let ret = self.0 as *mut GenericResponse;
+        mem::forget(self);
+        ret
+    }
 
-impl Deref for BinaryResponse {
-    type Target = BinaryResponseInner;
-    fn deref(&self) -> &Self::Target {
-        // SAFETY: We allocated this ourselves, or it was provided by PAM.
-        unsafe { &*self.0 }
+    /// The data type we point to.
+    pub fn data_type(&self) -> u8 {
+        // SAFETY: We allocated this ourselves or got it back from PAM.
+        unsafe { &*self.0 }.data_type()
+    }
+
+    /// The data we point to.
+    pub fn data(&self) -> &[u8] {
+        // SAFETY: We allocated this ourselves or got it back from PAM.
+        unsafe { &*self.0 }.contents()
     }
 }
 
@@ -63,19 +230,165 @@
     }
 }
 
+/// 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<BinaryResponse> for BinaryData {
+    /// Copies the data onto the Rust heap.
+    fn from(value: BinaryResponse) -> Self {
+        Self {
+            data: value.data().to_vec(),
+            data_type: value.data_type(),
+        }
+    }
+}
+
+impl From<BinaryData> for Vec<u8> {
+    /// Extracts the inner vector from the BinaryData.
+    fn from(value: BinaryData) -> Self {
+        value.data
+    }
+}
+
 #[cfg(test)]
 mod test {
-    use super::{BinaryResponse, TextResponse};
+    use super::{
+        BinaryResponse, Conversation, DemuxedConversation, Message, Response, SecureString,
+        TextResponse,
+    };
+    use crate::constants::ErrorCode;
+    use crate::pam_ffi::GenericResponse;
+
+    #[test]
+    fn test_demux() {
+        #[derive(Default)]
+        struct DemuxTester {
+            error_ran: bool,
+            info_ran: bool,
+        }
+
+        impl DemuxedConversation for DemuxTester {
+            fn prompt(&mut self, request: &str) -> crate::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> {
+                assert_eq!("reveal", request);
+                Ok(SecureString::from("my secrets"))
+            }
+            fn radio_prompt(&mut self, request: &str) -> crate::Result<String> {
+                assert_eq!("channel?", request);
+                Ok("zero".to_owned())
+            }
+            fn error(&mut self, message: &str) {
+                self.error_ran = true;
+                assert_eq!("whoopsie", message);
+            }
+            fn info(&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> {
+                assert_eq!(&[10, 9, 8], data);
+                assert_eq!(66, data_type);
+                Ok(super::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
+                .send(&[
+                    Message::Prompt("what"),
+                    Message::MaskedPrompt("reveal"),
+                    Message::Error("whoopsie"),
+                    Message::Info("did you know"),
+                ])
+                .unwrap()
+        );
+        assert!(tester.error_ran);
+        assert!(tester.info_ran);
+
+        assert_eq!(
+            ErrorCode::PermissionDenied,
+            tester.send(&[Message::Prompt("give_err")]).unwrap_err(),
+        );
+
+        // Test the Linux-PAM extensions separately.
+
+        assert_eq!(
+            vec![
+                Response::Text("zero".to_owned()),
+                Response::Binary(super::BinaryData::new(vec![5, 5, 5], 5)),
+            ],
+            tester
+                .send(&[
+                    Message::RadioPrompt("channel?"),
+                    Message::BinaryPrompt {
+                        data: &[10, 9, 8],
+                        data_type: 66
+                    },
+                ])
+                .unwrap()
+        );
+    }
+
+    // The below tests are used in conjunction with ASAN to verify
+    // that we correctly clean up all our memory.
 
     #[test]
     fn test_text_response() {
         let resp = TextResponse::new("it's a-me!").unwrap();
-        assert_eq!("it's a-me!", resp.contents().to_str().unwrap());
+        assert_eq!("it's a-me!", resp.as_str().unwrap());
     }
+
     #[test]
     fn test_binary_response() {
         let data = [123, 210, 55];
         let resp = BinaryResponse::new(&data, 99).unwrap();
-        assert_eq!(&data, resp.contents());
+        assert_eq!(data, resp.data());
+        assert_eq!(99, resp.data_type());
+    }
+
+    #[test]
+    fn test_to_generic() {
+        let text = TextResponse::new("oh no").unwrap();
+        let text = text.generic();
+        let binary = BinaryResponse::new(&[], 33).unwrap();
+        let binary = binary.generic();
+        unsafe {
+            GenericResponse::free(text);
+            GenericResponse::free(binary);
+        }
     }
 }