Implement a match table ready for aliases and triggers.

This commit is contained in:
Condorra 2024-09-23 22:38:51 +10:00
parent 0214897a91
commit b1bf0f317a
6 changed files with 503 additions and 1 deletions

39
Cargo.lock generated
View File

@ -30,6 +30,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.18" version = "0.2.18"
@ -992,6 +1001,35 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "regex"
version = "1.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -1355,6 +1393,7 @@ dependencies = [
"minicrossterm", "minicrossterm",
"nom", "nom",
"piccolo", "piccolo",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",

View File

@ -22,3 +22,4 @@ anyhow = "1.0.86"
serde = "1.0.209" serde = "1.0.209"
serde_json = "1.0.127" serde_json = "1.0.127"
gc-arena = { git = "https://github.com/kyren/gc-arena.git", rev = "5a7534b883b703f23cfb8c3cfdf033460aa77ea9" } gc-arena = { git = "https://github.com/kyren/gc-arena.git", rev = "5a7534b883b703f23cfb8c3cfdf033460aa77ea9" }
regex = "1.10.6"

View File

@ -7,6 +7,7 @@ use piccolo::{
self, async_sequence, Callback, CallbackReturn, Context, FromValue, Function, IntoValue, self, async_sequence, Callback, CallbackReturn, Context, FromValue, Function, IntoValue,
SequenceReturn, Table, UserData, Value, Variadic, SequenceReturn, Table, UserData, Value, Variadic,
}; };
use regex::Regex;
use std::{rc::Rc, str}; use std::{rc::Rc, str};
use yew::UseStateSetter; use yew::UseStateSetter;
@ -17,7 +18,35 @@ pub fn alias<'gc, 'a>(
_global_memo: &'a GlobalMemoCell, _global_memo: &'a GlobalMemoCell,
_global_layout: &'a UseStateSetter<GlobalLayoutCell>, _global_layout: &'a UseStateSetter<GlobalLayoutCell>,
) -> Callback<'gc> { ) -> Callback<'gc> {
Callback::from_fn(&ctx, move |_ctx, _ex, _stack| { Callback::from_fn(&ctx, move |ctx, _ex, mut stack| {
let info: Table = ctx.get_global("info")?;
let cur_frame: TermFrame =
try_unwrap_frame(ctx, &info.get(ctx, ctx.intern_static(b"current_frame"))?)?;
let frames: Table = ctx.get_global("frames")?;
let cur_frame: Table = frames.get(ctx, cur_frame.0 as i64)?;
let alias_match: piccolo::String = piccolo::String::from_value(
ctx,
stack
.pop_front()
.ok_or_else(|| anyhow::Error::msg("Missing alias match"))?,
)?;
let sub_to: piccolo::String = piccolo::String::from_value(
ctx,
stack
.pop_front()
.ok_or_else(|| anyhow::Error::msg("Missing substitution match"))?,
)?;
if !stack.is_empty() {
Err(anyhow::Error::msg(
"Extra arguments to alias command. Try wrapping the action in {}",
))?;
}
let aliases: Table = cur_frame.get(ctx, "aliases")?;
aliases.set(ctx, alias_match, sub_to)?;
Ok(piccolo::CallbackReturn::Return) Ok(piccolo::CallbackReturn::Return)
}) })
} }
@ -222,6 +251,9 @@ pub(super) fn new_frame<'gc>(ctx: Context<'gc>, _global_memo: &GlobalMemoCell) -
frame_tab.set(ctx, ctx.intern_static(b"frame"), frame)?; frame_tab.set(ctx, ctx.intern_static(b"frame"), frame)?;
let aliases_tab: Table = Table::new(&ctx);
frame_tab.set(ctx, ctx.intern_static(b"aliases"), aliases_tab)?;
Ok(piccolo::CallbackReturn::Return) Ok(piccolo::CallbackReturn::Return)
}) })
} }
@ -257,6 +289,20 @@ pub(super) fn frame_input<'gc>(ctx: Context<'gc>, _global_memo: &GlobalMemoCell)
.ok_or_else(|| anyhow::Error::msg("classes.frame:new missing line!"))?; .ok_or_else(|| anyhow::Error::msg("classes.frame:new missing line!"))?;
stack.consume(ctx)?; stack.consume(ctx)?;
// Check for an alias match...
for (alias_match, alias_sub) in frame_tab.get::<&str, Table>(ctx, "aliases")?.iter() {
if let Some(alias_match) = piccolo::String::from_value(ctx, alias_match)
.ok()
.and_then(|am| am.to_str().ok())
.and_then(|v| Regex::new(v).ok())
{
if let Some(alias_sub) = piccolo::String::from_value(ctx, alias_sub)
.ok()
.and_then(|am| am.to_str().ok())
{}
}
}
let linked_mud = frame_tab.get_value(ctx, ctx.intern_static(b"linked_mud")); let linked_mud = frame_tab.get_value(ctx, ctx.intern_static(b"linked_mud"));
if linked_mud.is_nil() { if linked_mud.is_nil() {
return Ok(piccolo::CallbackReturn::Return); return Ok(piccolo::CallbackReturn::Return);

View File

@ -8,6 +8,7 @@ pub mod command_handler;
pub mod id_intern; pub mod id_intern;
pub mod lineengine; pub mod lineengine;
pub mod lua_engine; pub mod lua_engine;
pub mod match_table;
pub mod parsing; pub mod parsing;
pub mod split_panel; pub mod split_panel;
pub mod telnet; pub mod telnet;

401
src/match_table.rs Normal file
View File

@ -0,0 +1,401 @@
use std::{collections::VecDeque, str::FromStr};
use anyhow::bail;
use itertools::Itertools;
use piccolo::{Context, IntoValue, Table, Value};
use regex::Regex;
use crate::parsing::{parse_commands, quote_string, ArgumentGuard, ParsedArgument, ParsedCommand};
#[derive(Default, Debug)]
pub struct MatchSubTable {
contents: Vec<MatchRecord>,
}
impl MatchSubTable {
pub fn to_value<'gc>(&self, ctx: Context<'gc>) -> anyhow::Result<Value<'gc>> {
let table = Table::new(&ctx);
for record in self.contents.iter() {
table.set(
ctx,
ctx.intern(record.match_text.as_bytes()),
ctx.intern(record.sub_text.as_bytes()),
)?;
}
Ok(table.into_value(ctx))
}
pub fn try_sub(&self, input: &str) -> Option<Vec<ParsedCommand>> {
for record in self.contents.iter() {
if let Some(matched) = record.match_regex.captures(input) {
let vec = Some(
record
.sub_commands
.iter()
.map(|subcmd| ParsedCommand {
arguments: subcmd
.arguments
.iter()
.map(|subarg| {
let unquoted_text = subarg
.text_parts
.iter()
.map(|tp| match tp {
SubTextPart::Literal(t) => t.as_str(),
SubTextPart::Variable(v) => {
if let Ok(v) = <usize as FromStr>::from_str(v) {
matched.get(v).map_or("", |v| v.as_str())
} else {
matched.name(v).map_or("", |v| v.as_str())
}
}
})
.join("");
ParsedArgument {
guard: if subarg.guard.is_none()
&& unquoted_text.contains(';')
{
Some(ArgumentGuard::Paren)
} else {
subarg.guard.clone()
},
text: unquoted_text.clone(),
quoted_text: quote_string(&unquoted_text),
}
})
.collect(),
})
.collect(),
);
return vec;
}
}
None
}
pub fn add_record(&mut self, match_text: &str, sub_text: &str) -> anyhow::Result<()> {
let rex = Regex::new(match_text)?;
let parse_result = parse_commands(sub_text);
let sub_commands: Vec<SubCommand> = parse_result
.commands
.into_iter()
.map(|cmd| {
Ok(SubCommand {
arguments: cmd
.arguments
.into_iter()
.map(parsedarg_to_subarg)
.collect::<anyhow::Result<VecDeque<SubArgument>>>()?,
})
})
.collect::<anyhow::Result<Vec<SubCommand>>>()?;
self.contents.push(MatchRecord {
match_text: match_text.to_owned(),
match_regex: rex,
sub_text: sub_text.to_owned(),
sub_commands,
});
Ok(())
}
}
fn parsedarg_to_subarg(parsedarg: ParsedArgument) -> anyhow::Result<SubArgument> {
let mut text_parts: Vec<SubTextPart> = vec![];
let mut iter = parsedarg.text.chars().peekable();
let mut buf = String::new();
'outer: loop {
match iter.next() {
None => break 'outer,
Some('$') => match iter.peek() {
None => {
bail!("substitution ends in $ which is invalid.")
}
Some('$') => {
iter.next();
buf.push('$')
}
Some('{') => {
iter.next();
if !buf.is_empty() {
text_parts.push(SubTextPart::Literal(buf));
}
buf = String::new();
'inner: loop {
match iter.next() {
None => {
bail!("substitution opened with {{ is never closed.")
}
Some('}') => {
if buf.is_empty() {
bail!("substitution of empty variable name.");
}
text_parts.push(SubTextPart::Variable(buf));
buf = String::new();
break 'inner;
}
Some(c) => buf.push(c),
}
}
}
Some(_) => {
if !buf.is_empty() {
text_parts.push(SubTextPart::Literal(buf));
}
buf = String::new();
'inner: loop {
match iter.peek() {
None => {
text_parts.push(SubTextPart::Variable(buf));
buf = String::new();
break 'outer;
}
Some(c) if *c == '_' || c.is_ascii_alphanumeric() => {
buf.push(*c);
iter.next();
}
Some(_) => {
if buf.is_empty() {
bail!("substitution of empty variable name.");
}
text_parts.push(SubTextPart::Variable(buf));
buf = String::new();
break 'inner;
}
}
}
}
},
Some(c) => buf.push(c),
}
}
if !buf.is_empty() {
text_parts.push(SubTextPart::Literal(buf))
}
Ok(SubArgument {
guard: parsedarg.guard,
text_parts,
})
}
#[derive(Debug)]
pub struct MatchRecord {
pub match_text: String,
pub match_regex: Regex,
pub sub_text: String,
// We parse into into commands and arguments upfront, before substitution.
// This reduces the risk of security problems.
pub sub_commands: Vec<SubCommand>,
}
#[derive(Debug)]
pub struct SubCommand {
pub arguments: VecDeque<SubArgument>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct SubArgument {
pub guard: Option<ArgumentGuard>,
pub text_parts: Vec<SubTextPart>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum SubTextPart {
Literal(String),
Variable(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsedarg_to_subarg_works() {
assert_eq!(
parsedarg_to_subarg(ParsedArgument {
guard: None,
text: "hello world!".to_owned(),
quoted_text: "hello world!".to_owned()
})
.unwrap(),
SubArgument {
guard: None,
text_parts: vec![SubTextPart::Literal("hello world!".to_owned())]
}
);
assert_eq!(
parsedarg_to_subarg(ParsedArgument {
guard: None,
text: "hello $adjective ${my world}".to_owned(),
quoted_text: "hello $adjective ${my world}".to_owned()
})
.unwrap(),
SubArgument {
guard: None,
text_parts: vec![
SubTextPart::Literal("hello ".to_owned()),
SubTextPart::Variable("adjective".to_owned()),
SubTextPart::Literal(" ".to_owned()),
SubTextPart::Variable("my world".to_owned()),
]
}
);
assert_eq!(
parsedarg_to_subarg(ParsedArgument {
guard: Some(ArgumentGuard::DoubleQuote),
text: "hello $adjective${my world}${your world} end".to_owned(),
quoted_text: "hello $adjective$\\{my world\\}$\\{your world\\} end".to_owned()
})
.unwrap(),
SubArgument {
guard: Some(ArgumentGuard::DoubleQuote),
text_parts: vec![
SubTextPart::Literal("hello ".to_owned()),
SubTextPart::Variable("adjective".to_owned()),
SubTextPart::Variable("my world".to_owned()),
SubTextPart::Variable("your world".to_owned()),
SubTextPart::Literal(" end".to_owned()),
]
}
);
}
#[test]
fn parsedarg_rejects_invalid() {
assert!(parsedarg_to_subarg(ParsedArgument {
guard: None,
text: "${untermin".to_owned(),
quoted_text: "${untermin".to_owned()
})
.is_err());
assert!(parsedarg_to_subarg(ParsedArgument {
guard: None,
text: "$foo$".to_owned(),
quoted_text: "$foo$".to_owned()
})
.is_err());
assert!(parsedarg_to_subarg(ParsedArgument {
guard: None,
text: "$ hello".to_owned(),
quoted_text: "$ hello".to_owned()
})
.is_err());
assert!(parsedarg_to_subarg(ParsedArgument {
guard: None,
text: "My name is ${}".to_owned(),
quoted_text: "My name is ${}".to_owned()
})
.is_err());
}
#[test]
fn matchsubtable_works() {
let mut table: MatchSubTable = Default::default();
table
.add_record(
"^foo (?<bar>[a-z]+) baz",
"\\\"Someone is talking $bar about foo baz?;:flexes his ${bar}",
)
.expect("adding record failed");
assert_eq!(table.try_sub("unrelated babble"), None);
assert_eq!(
table.try_sub("foo woots baz\r\n"),
Some(vec![
ParsedCommand {
arguments: [
ParsedArgument {
guard: None,
text: "\"Someone".to_owned(),
quoted_text: "\\\"Someone".to_owned()
},
ParsedArgument {
guard: None,
text: "is".to_owned(),
quoted_text: "is".to_owned()
},
ParsedArgument {
guard: None,
text: "talking".to_owned(),
quoted_text: "talking".to_owned()
},
ParsedArgument {
guard: None,
text: "woots".to_owned(),
quoted_text: "woots".to_owned()
},
ParsedArgument {
guard: None,
text: "about".to_owned(),
quoted_text: "about".to_owned()
},
ParsedArgument {
guard: None,
text: "foo".to_owned(),
quoted_text: "foo".to_owned()
},
ParsedArgument {
guard: None,
text: "baz?".to_owned(),
quoted_text: "baz?".to_owned()
}
]
.into()
},
ParsedCommand {
arguments: [
ParsedArgument {
guard: None,
text: ":flexes".to_owned(),
quoted_text: ":flexes".to_owned()
},
ParsedArgument {
guard: None,
text: "his".to_owned(),
quoted_text: "his".to_owned()
},
ParsedArgument {
guard: None,
text: "woots".to_owned(),
quoted_text: "woots".to_owned()
}
]
.into()
}
])
);
}
#[test]
fn matchsubtable_resists_command_injection() {
let mut table: MatchSubTable = Default::default();
table
.add_record("^foo (.*)", "safe_command $1")
.expect("adding record failed");
let result = table
.try_sub("foo pwned!};dangerous_command {")
.expect("didn't match");
let expected = ParsedCommand {
arguments: [
ParsedArgument {
guard: None,
text: "safe_command".to_owned(),
quoted_text: "safe_command".to_owned(),
},
ParsedArgument {
guard: Some(ArgumentGuard::Paren),
text: "pwned!};dangerous_command {".to_owned(),
quoted_text: "pwned!\\};dangerous_command \\{".to_owned(),
},
]
.into(),
};
assert_eq!(result, vec![expected.clone()]);
let ser_result = result[0].to_string();
assert_eq!(
ser_result,
"safe_command {pwned!\\};dangerous_command \\{}".to_owned()
);
assert_eq!(parse_commands(&ser_result).commands, vec![expected]);
}
}

View File

@ -140,6 +140,20 @@ fn unquote_string(input: &str) -> String {
} }
} }
pub fn quote_string(input: &str) -> String {
let mut buf: String = String::new();
for c in input.chars() {
match c {
'\\' => buf.push_str("\\\\"),
'{' => buf.push_str("\\{"),
'}' => buf.push_str("\\}"),
'"' => buf.push_str("\\\""),
c => buf.push(c),
}
}
buf
}
fn parse_string(input: &str) -> IResult<&str, ()> { fn parse_string(input: &str) -> IResult<&str, ()> {
value( value(
(), (),