Skip to main content

shellhist_core/
powershell.rs

1//! PowerShell PSReadLine history (`ConsoleHost_history.txt`).
2//!
3//! Plain one-command-per-line with **no timestamps** (Microsoft *about_PSReadLine*).
4//! A command spanning multiple lines ends each non-final physical line with a
5//! trailing backtick (PowerShell's line-continuation char); the reader rejoins
6//! them. A leading UTF-8 BOM is stripped if present.
7//!
8//! Note for the analyzer: PSReadLine refuses to persist lines containing
9//! `password`/`token`/`secret`/`apikey`/`asplaintext`, so the *absence* of a
10//! credential command here is not evidence it was never run — a coverage caveat,
11//! never a negative finding.
12
13use crate::{HistoryEntry, Shell};
14
15fn ends_with_continuation(line: &str) -> bool {
16    // A trailing backtick continues the command. An even run of backticks is an
17    // escaped literal backtick, not a continuation.
18    line.bytes().rev().take_while(|&b| b == b'`').count() % 2 == 1
19}
20
21/// Parse PSReadLine history bytes into entries (all timestamps `None`).
22#[must_use]
23pub fn parse(data: &[u8]) -> Vec<HistoryEntry> {
24    let text = String::from_utf8_lossy(crate::strip_bom(data));
25    let mut entries = Vec::new();
26    let mut current = String::new();
27    let mut continuing = false;
28
29    for line in text.split('\n') {
30        if continuing {
31            current.push('\n');
32            current.push_str(line);
33        } else {
34            current = line.to_string();
35        }
36        if ends_with_continuation(line) {
37            current.pop(); // drop the trailing backtick
38            continuing = true;
39        } else {
40            if !current.is_empty() {
41                entries.push(HistoryEntry::plain(
42                    Shell::PowerShell,
43                    std::mem::take(&mut current),
44                ));
45            }
46            current.clear();
47            continuing = false;
48        }
49    }
50    if continuing && !current.is_empty() {
51        entries.push(HistoryEntry::plain(Shell::PowerShell, current));
52    }
53    entries
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn plain_lines_become_entries_without_timestamps() {
62        let e = parse(b"Get-Process\nGet-ChildItem\n");
63        assert_eq!(e.len(), 2);
64        assert_eq!(e[0].command, "Get-Process");
65        assert!(e.iter().all(|x| x.timestamp.is_none()));
66        assert_eq!(e[0].shell, Shell::PowerShell);
67    }
68
69    #[test]
70    fn leading_bom_is_stripped() {
71        let e = parse(b"\xEF\xBB\xBFGet-Process\n");
72        assert_eq!(e.len(), 1);
73        assert_eq!(e[0].command, "Get-Process");
74    }
75
76    #[test]
77    fn backtick_continuation_rejoins_a_multiline_command() {
78        let e = parse(b"Get-Process |`\nWhere-Object CPU -gt 1\nls\n");
79        assert_eq!(e.len(), 2);
80        assert_eq!(e[0].command, "Get-Process |\nWhere-Object CPU -gt 1");
81        assert_eq!(e[1].command, "ls");
82    }
83
84    #[test]
85    fn empty_input_yields_no_entries() {
86        assert!(parse(b"").is_empty());
87    }
88}