view 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 source

//! The PAM conversation and associated Stuff.

// 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.
///
/// It points to a value on the C heap.
#[repr(C)]
struct TextResponse(*mut TextResponseInner);

impl TextResponse {
    /// 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)
    }

    /// 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()
    }
}

impl Drop for TextResponse {
    /// Frees an owned response.
    fn drop(&mut self) {
        // SAFETY: We allocated this ourselves, or it was provided by PAM.
        unsafe { TextResponseInner::free(self.0) }
    }
}

/// An owned binary response to a PAM conversation.
///
/// It points to a value on the C heap.
#[repr(C)]
pub struct BinaryResponse(pub(super) *mut BinaryResponseInner);

impl BinaryResponse {
    /// Creates a binary response with the given data.
    ///
    /// 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
    }

    /// 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()
    }
}

impl Drop for BinaryResponse {
    /// Frees an owned response.
    fn drop(&mut self) {
        // SAFETY: We allocated this ourselves, or it was provided by PAM.
        unsafe { BinaryResponseInner::free(self.0) }
    }
}

/// 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, 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.as_str().unwrap());
    }

    #[test]
    fn test_binary_response() {
        let data = [123, 210, 55];
        let resp = BinaryResponse::new(&data, 99).unwrap();
        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);
        }
    }
}