Skip to main content

psrp_rs/
metadata.rs

1//! `Get-Command` metadata pipeline — a special kind of PSRP pipeline
2//! that asks the server which commands are available.
3//!
4//! The server uses this to implement implicit remoting (`Import-PSSession`).
5//! Unlike a normal `CreatePipeline`, the body is a `GetCommandMetadata`
6//! message (`0x0002_100A`) that carries a list of name patterns and the
7//! command types to return.
8
9use uuid::Uuid;
10
11use crate::clixml::{PsObject, PsValue, parse_clixml, to_clixml};
12use crate::error::{PsrpError, Result};
13use crate::message::MessageType;
14use crate::pipeline::PipelineState;
15use crate::runspace::RunspacePool;
16use crate::transport::PsrpTransport;
17
18/// Bitmask of command types to query, mirroring
19/// `System.Management.Automation.CommandTypes`.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct CommandType(u32);
22
23impl CommandType {
24    pub const ALIAS: Self = Self(0x0001);
25    pub const FUNCTION: Self = Self(0x0002);
26    pub const FILTER: Self = Self(0x0004);
27    pub const CMDLET: Self = Self(0x0008);
28    pub const EXTERNAL_SCRIPT: Self = Self(0x0010);
29    pub const APPLICATION: Self = Self(0x0020);
30    pub const SCRIPT: Self = Self(0x0040);
31    pub const WORKFLOW: Self = Self(0x0080);
32    pub const CONFIGURATION: Self = Self(0x0100);
33    pub const ALL: Self = Self(0x01FF);
34
35    #[must_use]
36    pub const fn empty() -> Self {
37        Self(0)
38    }
39    #[must_use]
40    pub const fn bits(self) -> u32 {
41        self.0
42    }
43    #[must_use]
44    pub const fn contains(self, other: Self) -> bool {
45        (self.0 & other.0) == other.0
46    }
47}
48
49impl std::ops::BitOr for CommandType {
50    type Output = Self;
51    fn bitor(self, rhs: Self) -> Self::Output {
52        Self(self.0 | rhs.0)
53    }
54}
55
56impl std::ops::BitAnd for CommandType {
57    type Output = Self;
58    fn bitand(self, rhs: Self) -> Self::Output {
59        Self(self.0 & rhs.0)
60    }
61}
62
63/// Describe one command returned by a metadata query.
64#[derive(Debug, Clone, Default, PartialEq)]
65pub struct CommandMetadata {
66    pub name: String,
67    pub namespace: Option<String>,
68    pub has_common_parameters: Option<bool>,
69    pub command_type: Option<i32>,
70    pub parameters: Vec<ParameterMetadata>,
71}
72
73/// One parameter of a [`CommandMetadata`] entry.
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct ParameterMetadata {
76    pub name: String,
77    pub parameter_type: Option<String>,
78    pub is_mandatory: Option<bool>,
79    pub position: Option<i32>,
80}
81
82impl CommandMetadata {
83    fn from_ps_object(value: &PsValue) -> Option<Self> {
84        let obj = value.properties()?;
85        Some(Self {
86            name: obj
87                .get("Name")
88                .and_then(PsValue::as_str)
89                .unwrap_or_default()
90                .to_string(),
91            namespace: obj
92                .get("Namespace")
93                .and_then(PsValue::as_str)
94                .map(str::to_string),
95            has_common_parameters: obj.get("HasCommonParameters").and_then(PsValue::as_bool),
96            command_type: obj.get("CommandType").and_then(PsValue::as_i32),
97            parameters: match obj.get("Parameters") {
98                Some(PsValue::List(list)) => list
99                    .iter()
100                    .filter_map(ParameterMetadata::from_ps_value)
101                    .collect(),
102                _ => Vec::new(),
103            },
104        })
105    }
106}
107
108impl ParameterMetadata {
109    fn from_ps_value(value: &PsValue) -> Option<Self> {
110        let obj = value.properties()?;
111        Some(Self {
112            name: obj
113                .get("Name")
114                .and_then(PsValue::as_str)
115                .unwrap_or_default()
116                .to_string(),
117            parameter_type: obj
118                .get("ParameterType")
119                .and_then(PsValue::as_str)
120                .map(str::to_string),
121            is_mandatory: obj.get("IsMandatory").and_then(PsValue::as_bool),
122            position: obj.get("Position").and_then(PsValue::as_i32),
123        })
124    }
125}
126
127impl<T: PsrpTransport> RunspacePool<T> {
128    /// Ask the server for metadata about every command matching `patterns`
129    /// (wildcards accepted) whose type intersects `command_type`.
130    ///
131    /// Sends a `GetCommandMetadata` message, drains the resulting
132    /// pipeline, and returns a decoded [`CommandMetadata`] list.
133    pub async fn get_command_metadata(
134        &mut self,
135        patterns: &[&str],
136        command_type: CommandType,
137    ) -> Result<Vec<CommandMetadata>> {
138        let pid = Uuid::new_v4();
139        let body = build_get_command_metadata_body(patterns, command_type);
140        self.send_pipeline_message(MessageType::GetCommandMetadata, pid, body)
141            .await?;
142
143        let mut out = Vec::new();
144        loop {
145            let msg = self.next_message().await?;
146            match msg.message_type {
147                MessageType::PipelineOutput => {
148                    for v in parse_clixml(&msg.data)? {
149                        if let Some(cm) = CommandMetadata::from_ps_object(&v) {
150                            out.push(cm);
151                        }
152                    }
153                }
154                MessageType::PipelineState => {
155                    if let Some(state) = state_from_xml(&msg.data) {
156                        if state.is_terminal() {
157                            if state == PipelineState::Failed {
158                                return Err(PsrpError::PipelineFailed(
159                                    "GetCommandMetadata pipeline failed".into(),
160                                ));
161                            }
162                            return Ok(out);
163                        }
164                    }
165                }
166                _ => continue,
167            }
168        }
169    }
170}
171
172fn state_from_xml(xml: &str) -> Option<PipelineState> {
173    parse_clixml(xml).ok().and_then(|values| {
174        values.into_iter().find_map(|v| match v {
175            PsValue::Object(obj) => obj
176                .get("PipelineState")
177                .and_then(PsValue::as_i32)
178                .map(pipeline_state_from_i32),
179            _ => None,
180        })
181    })
182}
183
184fn pipeline_state_from_i32(v: i32) -> PipelineState {
185    // Mirror pipeline::PipelineState::from_i32 without exposing it.
186    match v {
187        0 => PipelineState::NotStarted,
188        1 => PipelineState::Running,
189        2 => PipelineState::Stopping,
190        3 => PipelineState::Stopped,
191        4 => PipelineState::Completed,
192        5 => PipelineState::Failed,
193        6 => PipelineState::Disconnected,
194        _ => PipelineState::Unknown,
195    }
196}
197
198fn build_get_command_metadata_body(patterns: &[&str], command_type: CommandType) -> String {
199    let names = PsValue::List(
200        patterns
201            .iter()
202            .map(|p| PsValue::String((*p).to_string()))
203            .collect(),
204    );
205    let obj = PsObject::new()
206        .with("Name", names)
207        .with("CommandType", PsValue::I32(command_type.bits() as i32))
208        .with("Namespace", PsValue::List(Vec::new()))
209        .with("ArgumentList", PsValue::List(Vec::new()));
210    to_clixml(&PsValue::Object(obj))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::clixml::PsObject;
217
218    #[test]
219    fn command_type_constants() {
220        assert_eq!(CommandType::CMDLET.bits(), 0x0008);
221        assert_eq!(CommandType::ALL.bits(), 0x01FF);
222        let combo = CommandType::CMDLET | CommandType::FUNCTION;
223        assert!(combo.contains(CommandType::CMDLET));
224        assert!(combo.contains(CommandType::FUNCTION));
225        assert!(!combo.contains(CommandType::ALIAS));
226    }
227
228    #[test]
229    fn get_command_metadata_body_contains_name_and_type() {
230        let body = build_get_command_metadata_body(&["Get-*", "Set-*"], CommandType::CMDLET);
231        assert!(body.contains("<S>Get-*</S>"));
232        assert!(body.contains("<S>Set-*</S>"));
233        // CommandType::CMDLET = 0x0008 = 8
234        assert!(body.contains("<I32 N=\"CommandType\">8</I32>"));
235    }
236
237    #[test]
238    fn decode_command_metadata_object() {
239        let obj = PsObject::new()
240            .with("Name", PsValue::String("Get-Date".into()))
241            .with("HasCommonParameters", PsValue::Bool(true))
242            .with("CommandType", PsValue::I32(8))
243            .with(
244                "Parameters",
245                PsValue::List(vec![PsValue::Object(
246                    PsObject::new()
247                        .with("Name", PsValue::String("Format".into()))
248                        .with("ParameterType", PsValue::String("System.String".into()))
249                        .with("IsMandatory", PsValue::Bool(false))
250                        .with("Position", PsValue::I32(0)),
251                )]),
252            );
253        let cm = CommandMetadata::from_ps_object(&PsValue::Object(obj)).unwrap();
254        assert_eq!(cm.name, "Get-Date");
255        assert_eq!(cm.has_common_parameters, Some(true));
256        assert_eq!(cm.command_type, Some(8));
257        assert_eq!(cm.parameters.len(), 1);
258        assert_eq!(cm.parameters[0].name, "Format");
259        assert_eq!(
260            cm.parameters[0].parameter_type.as_deref(),
261            Some("System.String")
262        );
263    }
264
265    #[test]
266    fn decode_rejects_non_object() {
267        assert!(CommandMetadata::from_ps_object(&PsValue::I32(1)).is_none());
268    }
269
270    #[test]
271    fn pipeline_state_shim_matches() {
272        assert_eq!(pipeline_state_from_i32(0), PipelineState::NotStarted);
273        assert_eq!(pipeline_state_from_i32(1), PipelineState::Running);
274        assert_eq!(pipeline_state_from_i32(2), PipelineState::Stopping);
275        assert_eq!(pipeline_state_from_i32(3), PipelineState::Stopped);
276        assert_eq!(pipeline_state_from_i32(4), PipelineState::Completed);
277        assert_eq!(pipeline_state_from_i32(5), PipelineState::Failed);
278        assert_eq!(pipeline_state_from_i32(6), PipelineState::Disconnected);
279        assert_eq!(pipeline_state_from_i32(99), PipelineState::Unknown);
280    }
281
282    #[test]
283    fn state_from_xml_missing_is_none() {
284        assert!(state_from_xml("<Obj RefId=\"0\"><MS/></Obj>").is_none());
285    }
286
287    #[test]
288    fn state_from_xml_ok() {
289        let xml = to_clixml(&PsValue::Object(
290            PsObject::new().with("PipelineState", PsValue::I32(4)),
291        ));
292        assert_eq!(state_from_xml(&xml), Some(PipelineState::Completed));
293    }
294
295    // ---------- Phase D: end-to-end tests ----------
296
297    use crate::fragment::encode_message;
298    use crate::message::{Destination, PsrpMessage};
299    use crate::runspace::RunspacePoolState;
300    use crate::transport::mock::MockTransport;
301    use uuid::Uuid;
302
303    fn wire_msg(mt: MessageType, data: String) -> Vec<u8> {
304        PsrpMessage {
305            destination: Destination::Client,
306            message_type: mt,
307            rpid: Uuid::nil(),
308            pid: Uuid::nil(),
309            data,
310        }
311        .encode()
312    }
313
314    fn opened_state() -> Vec<u8> {
315        wire_msg(
316            MessageType::RunspacePoolState,
317            to_clixml(&PsValue::Object(PsObject::new().with(
318                "RunspaceState",
319                PsValue::I32(RunspacePoolState::Opened as i32),
320            ))),
321        )
322    }
323
324    fn pipeline_state(state: PipelineState) -> Vec<u8> {
325        wire_msg(
326            MessageType::PipelineState,
327            to_clixml(&PsValue::Object(
328                PsObject::new().with("PipelineState", PsValue::I32(state as i32)),
329            )),
330        )
331    }
332
333    #[tokio::test]
334    async fn get_command_metadata_returns_items() {
335        let t = MockTransport::new();
336        t.push_incoming(encode_message(1, &opened_state()));
337
338        // Two cmdlets emitted as PipelineOutput.
339        let cmd = |name: &str| {
340            to_clixml(&PsValue::Object(
341                PsObject::new()
342                    .with("Name", PsValue::String(name.into()))
343                    .with("CommandType", PsValue::I32(8)),
344            ))
345        };
346        t.push_incoming(encode_message(
347            10,
348            &wire_msg(MessageType::PipelineOutput, cmd("Get-Date")),
349        ));
350        t.push_incoming(encode_message(
351            11,
352            &wire_msg(MessageType::PipelineOutput, cmd("Get-Process")),
353        ));
354        t.push_incoming(encode_message(
355            12,
356            &pipeline_state(PipelineState::Completed),
357        ));
358
359        let mut pool = crate::runspace::RunspacePool::open_with_transport(t.clone())
360            .await
361            .unwrap();
362        let cmds = pool
363            .get_command_metadata(&["Get-*"], CommandType::CMDLET)
364            .await
365            .unwrap();
366        assert_eq!(cmds.len(), 2);
367        assert_eq!(cmds[0].name, "Get-Date");
368        assert_eq!(cmds[1].name, "Get-Process");
369        let _ = pool.close().await;
370    }
371
372    #[tokio::test]
373    async fn get_command_metadata_failed_pipeline_errors() {
374        let t = MockTransport::new();
375        t.push_incoming(encode_message(1, &opened_state()));
376        t.push_incoming(encode_message(10, &pipeline_state(PipelineState::Failed)));
377        let mut pool = crate::runspace::RunspacePool::open_with_transport(t)
378            .await
379            .unwrap();
380        let err = pool
381            .get_command_metadata(&["Nothing"], CommandType::ALL)
382            .await
383            .unwrap_err();
384        assert!(matches!(err, crate::error::PsrpError::PipelineFailed(_)));
385        let _ = pool.close().await;
386    }
387
388    #[tokio::test]
389    async fn get_command_metadata_empty_result() {
390        let t = MockTransport::new();
391        t.push_incoming(encode_message(1, &opened_state()));
392        t.push_incoming(encode_message(
393            10,
394            &pipeline_state(PipelineState::Completed),
395        ));
396        let mut pool = crate::runspace::RunspacePool::open_with_transport(t)
397            .await
398            .unwrap();
399        let cmds = pool
400            .get_command_metadata(&["None-*"], CommandType::CMDLET)
401            .await
402            .unwrap();
403        assert!(cmds.is_empty());
404        let _ = pool.close().await;
405    }
406
407    #[test]
408    fn command_type_bit_and() {
409        let mask = CommandType::ALL & CommandType::CMDLET;
410        assert_eq!(mask.bits(), CommandType::CMDLET.bits());
411        let empty = CommandType::empty();
412        assert_eq!(empty.bits(), 0);
413    }
414
415    #[test]
416    fn command_type_bit_or() {
417        let combined = CommandType::CMDLET | CommandType::FUNCTION;
418        assert!(combined.contains(CommandType::CMDLET));
419        assert!(combined.contains(CommandType::FUNCTION));
420        assert!(!combined.contains(CommandType::ALIAS));
421        // Verify OR produces the correct bits (not XOR)
422        assert_eq!(
423            combined.bits(),
424            CommandType::CMDLET.bits() | CommandType::FUNCTION.bits()
425        );
426        // OR with self should be idempotent (XOR would zero it out)
427        let double = CommandType::CMDLET | CommandType::CMDLET;
428        assert!(double.contains(CommandType::CMDLET));
429        assert_eq!(double.bits(), CommandType::CMDLET.bits());
430    }
431}