view src/libpam/question.rs @ 98:b87100c5eed4

Start on environment variables, and make pointers nicer. This starts work on the PAM environment handling, and in so doing, introduces the CHeapBox and CHeapString structs. These are analogous to Box and CString, but they're located on the C heap rather than being Rust-managed memory. This is because environment variables deal with even more pointers and it turns out we can lose a lot of manual freeing using homemade smart pointers.
author Paul Fisher <paul@pfish.zone>
date Tue, 24 Jun 2025 04:25:25 -0400
parents efc2b56c8928
children 94b51fa4f797
line wrap: on
line source

//! Data and types dealing with PAM messages.

#[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::{CBinaryData, CHeapBox, CHeapString, Immovable};
use crate::libpam::pam_ffi;
pub use crate::libpam::pam_ffi::Question;
use crate::ErrorCode;
use crate::Result;
use num_enum::{IntoPrimitive, TryFromPrimitive};
use std::ffi::{c_void, CStr};
use std::slice;

/// Abstraction of a collection of questions to be sent in a PAM conversation.
///
/// The PAM C API conversation function looks like this:
///
/// ```c
/// int pam_conv(
///     int count,
///     const struct pam_message **questions,
///     struct pam_response **answers,
///     void *appdata_ptr,
/// )
/// ```
///
/// On Linux-PAM and other compatible implementations, `questions`
/// is treated as a pointer-to-pointers, like `int argc, char **argv`.
/// (In this situation, the value of `Questions.indirect` is
/// the pointer passed to `pam_conv`.)
///
/// ```text
///            points to  ┌───────────────┐      ╔═ Question ═╗
/// questions ┄┄┄┄┄┄┄┄┄┄> │ questions[0] ┄┼┄┄┄┄> ║ style      ║
///                       │ questions[1] ┄┼┄┄┄╮  ║ data ┄┄┄┄┄┄╫┄┄> ...
///                       │ ...           │   ┆  ╚════════════╝
///                                           ┆
///                                           ┆    ╔═ Question ═╗
///                                           ╰┄┄> ║ style      ║
///                                                ║ data ┄┄┄┄┄┄╫┄┄> ...
///                                                ╚════════════╝
/// ```
///
/// On OpenPAM and other compatible implementations (like Solaris),
/// `messages` is a pointer-to-pointer-to-array.  This appears to be
/// the correct implementation as required by the XSSO specification.
///
/// ```text
///            points to  ┌─────────────┐       ╔═ Question[] ═╗
/// questions ┄┄┄┄┄┄┄┄┄┄> │ *questions ┄┼┄┄┄┄┄> ║ style        ║
///                       └─────────────┘       ║ data ┄┄┄┄┄┄┄┄╫┄┄> ...
///                                             ╟──────────────╢
///                                             ║ style        ║
///                                             ║ data ┄┄┄┄┄┄┄┄╫┄┄> ...
///                                             ╟──────────────╢
///                                             ║ ...          ║
/// ```
pub trait QuestionsTrait {
    /// Allocates memory for this indirector and all its members.
    fn new(messages: &[Message]) -> Result<Self>
    where
        Self: Sized;

    /// Gets the pointer that is passed .
    fn ptr(&self) -> *const *const Question;

    /// Converts a pointer into a borrowed list of Questions.
    ///
    /// # Safety
    ///
    /// You have to provide a valid pointer.
    unsafe fn borrow_ptr<'a>(
        ptr: *const *const Question,
        count: usize,
    ) -> impl Iterator<Item = &'a Question>;
}

#[cfg(pam_impl = "linux-pam")]
pub type Questions = LinuxPamQuestions;

#[cfg(not(pam_impl = "linux-pam"))]
pub type Questions = XSsoQuestions;

/// The XSSO standard version of the pointer train to questions.
#[derive(Debug)]
#[repr(C)]
pub struct XSsoQuestions {
    /// Points to the memory address where the meat of `questions` is.
    /// **The memory layout of Vec is not specified**, and we need to return
    /// a pointer to the pointer, hence we have to store it here.
    pointer: *const Question,
    questions: Vec<Question>,
    _marker: Immovable,
}

impl XSsoQuestions {
    fn len(&self) -> usize {
        self.questions.len()
    }
    fn iter_mut(&mut self) -> impl Iterator<Item = &mut Question> {
        self.questions.iter_mut()
    }
}

impl QuestionsTrait for XSsoQuestions {
    fn new(messages: &[Message]) -> Result<Self> {
        let questions: Result<Vec<_>> = messages.iter().map(Question::try_from).collect();
        let questions = questions?;
        Ok(Self {
            pointer: questions.as_ptr(),
            questions,
            _marker: Default::default(),
        })
    }

    fn ptr(&self) -> *const *const Question {
        &self.pointer as *const *const Question
    }

    unsafe fn borrow_ptr<'a>(
        ptr: *const *const Question,
        count: usize,
    ) -> impl Iterator<Item = &'a Question> {
        slice::from_raw_parts(*ptr, count).iter()
    }
}

/// The Linux version of the pointer train to questions.
#[derive(Debug)]
#[repr(C)]
pub struct LinuxPamQuestions {
    #[allow(clippy::vec_box)] // we need to do this.
    /// The place where the questions are.
    questions: Vec<Box<Question>>,
    _marker: Immovable,
}

impl LinuxPamQuestions {
    fn len(&self) -> usize {
        self.questions.len()
    }

    fn iter_mut(&mut self) -> impl Iterator<Item = &mut Question> {
        self.questions.iter_mut().map(AsMut::as_mut)
    }
}

impl QuestionsTrait for LinuxPamQuestions {
    fn new(messages: &[Message]) -> Result<Self> {
        let questions: Result<_> = messages
            .iter()
            .map(|msg| Question::try_from(msg).map(Box::new))
            .collect();
        Ok(Self {
            questions: questions?,
            _marker: Default::default(),
        })
    }

    fn ptr(&self) -> *const *const Question {
        self.questions.as_ptr().cast()
    }

    unsafe fn borrow_ptr<'a>(
        ptr: *const *const Question,
        count: usize,
    ) -> impl Iterator<Item = &'a Question> {
        slice::from_raw_parts(ptr.cast::<&Question>(), count)
            .iter()
            .copied()
    }
}

/// The C enum values for messages shown to the user.
#[derive(Debug, PartialEq, TryFromPrimitive, IntoPrimitive)]
#[repr(u32)]
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 Question {
    /// Gets this message's data pointer as a string.
    ///
    /// # Safety
    ///
    /// It's up to you to pass this only on types with a string value.
    unsafe fn string_data(&self) -> Result<&str> {
        match self.data.as_ref() {
            None => Ok(""),
            Some(data) => CStr::from_ptr(CHeapBox::as_ptr(data).cast().as_ptr())
                .to_str()
                .map_err(|_| ErrorCode::ConversationError),
        }
    }

    /// Gets this message's data pointer as borrowed binary data.
    unsafe fn binary_data(&self) -> (&[u8], u8) {
        self.data
            .as_ref()
            .map(|data| CBinaryData::data(CHeapBox::as_ptr(data).cast()))
            .unwrap_or_default()
    }
}

impl TryFrom<&Message<'_>> for Question {
    type Error = ErrorCode;
    fn try_from(msg: &Message) -> Result<Self> {
        let alloc = |style, text| -> Result<_> {
            Ok((style, unsafe {
                CHeapBox::cast(CHeapString::new(text)?.into_box())
            }))
        };
        // We will only allocate heap data if we have a valid input.
        let (style, data): (_, CHeapBox<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, unsafe {
                CHeapBox::cast(CBinaryData::alloc(p.question())?)
            })),
            #[cfg(not(feature = "linux-pam-extensions"))]
            Message::RadioPrompt(_) | Message::BinaryPrompt(_) => Err(ErrorCode::ConversationError),
        }?;
        Ok(Self {
            style: style.into(),
            data: Some(data),
            _marker: Default::default(),
        })
    }
}

impl Drop for Question {
    fn drop(&mut self) {
        // 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) {
                let _ = match style {
                    #[cfg(feature = "linux-pam-extensions")]
                    Style::BinaryPrompt => self
                        .data
                        .as_ref()
                        .map(|p| CBinaryData::zero_contents(CHeapBox::as_ptr(p).cast())),
                    #[cfg(feature = "linux-pam-extensions")]
                    Style::RadioType => self
                        .data
                        .as_ref()
                        .map(|p| CHeapString::zero(CHeapBox::as_ptr(p).cast())),
                    Style::TextInfo
                    | Style::ErrorMsg
                    | Style::PromptEchoOff
                    | Style::PromptEchoOn => self
                        .data
                        .as_ref()
                        .map(|p| CHeapString::zero(CHeapBox::as_ptr(p).cast())),
                };
            };
        }
    }
}

impl<'a> TryFrom<&'a Question> for OwnedMessage<'a> {
    type Error = ErrorCode;
    fn try_from(question: &'a Question) -> Result<Self> {
        let style: Style = question
            .style
            .try_into()
            .map_err(|_| ErrorCode::ConversationError)?;
        // 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 => {
                    Self::MaskedPrompt(MaskedQAndA::new(question.string_data()?))
                }
                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())),
            }
        };
        Ok(prompt)
    }
}

#[cfg(test)]
mod tests {

    macro_rules! assert_matches {
        ($id:ident => $variant:path, $q:expr) => {
            if let $variant($id) = $id {
                assert_eq!($q, $id.question());
            } else {
                panic!("mismatched enum variant {x:?}", x = $id);
            }
        };
    }

    macro_rules! tests { ($fn_name:ident<$typ:ident>) => {
        mod $fn_name {
            use super::super::*;
            #[test]
            fn standard() {
                let interrogation = <$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.ptr();

                let remade = unsafe { $typ::borrow_ptr(indirect, interrogation.len()) };
                let messages: Vec<OwnedMessage> = remade
                    .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");
            }

            #[test]
            #[cfg(not(feature = "linux-pam-extensions"))]
            fn no_linux_extensions() {
                use crate::conv::{BinaryQAndA, RadioQAndA};
                <$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 = <$typ>::new(&[
                    BinaryQAndA::new((&[5, 4, 3, 2, 1], 66)).message(),
                    RadioQAndA::new("you must choose").message(),
                ]).unwrap();
                let indirect = interrogation.ptr();

                let remade = unsafe { $typ::borrow_ptr(indirect, interrogation.len()) };
                let messages: Vec<OwnedMessage> = remade
                    .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");
            }
        }
    }}

    tests!(test_xsso<XSsoQuestions>);
    tests!(test_linux<LinuxPamQuestions>);
}