File Coverage

blib/lib/FSM/Basic.pm
Criterion Covered Total %
statement 113 141 80.1
branch 35 52 67.3
condition 30 55 54.5
subroutine 9 10 90.0
pod 1 4 25.0
total 188 262 71.7


line stmt bran cond sub pod time code
1             package FSM::Basic;
2              
3 9     9   486932 use 5.010;
  9         87  
4 9     9   35 use strict;
  9         14  
  9         131  
5 9     9   31 use warnings;
  9         12  
  9         12661  
6              
7             =head1 NAME
8              
9             FSM::Basic - Finite state machine using HASH as state definitions
10              
11             =head1 VERSION
12              
13             Version 0.15
14              
15             =cut
16              
17             our $VERSION = '0.15';
18              
19             =head1 SYNOPSIS
20              
21             A small finite state machine using a HASH data as description of the states
22             Mainly used to create fake bash or fake telnet server in the purpose to mimic some CLI device interface (like SWITCH or ROUTER interface)
23             Perhaps a little code snippet.
24             The HASH is easily using a JSON file
25              
26             use FSM::Basic;
27              
28             my $fsm = FSM::Basic->new( \%states, 'accept' );
29             my $final = 0;
30             my $out;
31             foreach my $in ( @ins )
32             {
33             ( $final, $out ) = $fsm->run( $in );
34             say $out;
35             last if $final;
36             }
37              
38              
39             =head1 SUBROUTINES/METHODS
40              
41             =head2 new
42              
43             my $fsm = FSM::Basic->new( \%states, 'accept' );
44              
45             Create the FSM with the HASH ref as first paramter
46             and the initial state as second parameter
47              
48             The HASH is like this:
49              
50             my %states = (
51             'accept' => {
52             'expect' => {
53             'default' => {
54             'final' => 0,
55             'matching' => 'prompt'
56             }
57             },
58             'not_matching' => 'accept',
59             'not_matching0' => 'close',
60             'not_matching_info_last' => '% Bad passwords
61             ',
62             'output' => 'Password: ',
63             'repeat' => 2
64             },
65              
66             'close' => {'final' => 1},
67             'prompt' => {
68             'expect' => {
69             'not_matching' => 'prompt',
70             'exit' => {
71             'matching' => 'close',
72             'final' => 0
73             },
74             'meminfo' => {'do' => 'do { local( @ARGV, $/ ) = "/proc/meminfo" ; <> }'},
75             'h(elp)?|\\?' => {
76             'output' => 'exit
77             meminfo
78             mem_usage
79             User> '
80             },
81             'mem_usage' => {'do' => 'my ( $tot,$avail) = (split /\\n/ ,do { local( @ARGV, $/ ) = "/proc/meminfo" ; <> })[0,2];$tot =~ s/\\D*//g; $avail =~ s/\\D*//g; sprintf "%0.2f%%\\n",(100*($tot-$avail)/$tot); '},
82             },
83             'not_matching_info' => '% Unknown command or computer name, or unable to find computer address',
84             'output' => 'User> '
85             }
86             );
87              
88              
89              
90             The keys are the states name.
91             "expect" contain a sub HASH where the keys are word or REGEX expected as input
92              
93             =over 1
94              
95             =item In this hash, you have :
96              
97             =over 2
98              
99             =item executable code:
100              
101              
102             "do" for perl code
103              
104              
105             "exec" for system code, and 2 specific commands:
106              
107             =over 4
108              
109             =item *
110             "cat" just reading the file content provided in parameter).
111              
112             =item *
113             "catRAND" chose randomly one of the files provided in parameter space separated
114              
115             =item *
116             "catWRAND" chose randomly (weighted) one of the files provided in parameter space separatedwith a : to separate the weight
117             e.g. 'catWRAND' => './t/test_cat.txt:1 ./t/test_cat1.txt:50', in this case the file ./t/test_cat1.txt get 50 more chance to be selected than file ./t/test_cat.txt
118              
119             =item *
120             "catSEQ" read sequentialy the next files provided in parameter space separated
121             if "catSEQ_idx" is defined, that file is used to keep the state. Otherwise , the state file is named used
122             all the files name from "catSEQ" concatenated with a final '.tate'. All spaces are replaced by an underscore
123              
124             =back
125              
126             =item
127              
128             "matching" to define the state when the input match the "expect" value
129              
130              
131             =item
132              
133             "final" to return a flag
134              
135              
136             =item
137              
138             "not_matching" the state if the input is not matching the "expect" value (if missing stay in the same state)
139              
140              
141             =item
142              
143             "repeat" a number of trial before the state goes to "not_matching0"
144              
145              
146             =item
147              
148             "not_matching0" the state when the number of failled matching number is reached
149              
150              
151             =item
152              
153             "not_matching_info_last" info returned as second value when the failled matching number is reached
154              
155              
156             =item
157              
158             "output" the info returned as second value
159              
160              
161             =back
162              
163             It is perfectly possible to add extra tag not used by FSM::Basic for generic purpose.
164             Check examples/fake_bash_ssh1.*
165             Take a look at timout and timer usage
166             In this example if destination IP from the SSH connection is available, the file IP.json is used as definition
167             (with fallback to fake_bash1.pl)
168              
169             =cut
170              
171             sub new {
172 8     8 1 5603 my ($class, $l, $s) = @_;
173 8         15 my $self;
174 8         20 $self->{states_list} = $l;
175 8         18 $self->{state} = $s;
176 8         16 bless($self, $class);
177 8         16 return $self;
178             }
179              
180             =back
181             =head2 run
182              
183             my ( $final, $out ) = $fsm->run( $in );
184              
185             Run the FSM with the input and return the expected output and an extra flag
186              
187              
188             =cut
189              
190             sub run {
191 40015     40015 0 314661 my ($self, $in) = @_;
192              
193 40015         40872 my $output = '';
194 40015 50       67341 if (exists $self->{states_list}) {
195 40015 100 66     120888 if ( exists $self->{states_list}{ $self->{state} }
      100        
196             && exists $self->{states_list}{ $self->{state} }{repeat}
197             && $self->{states_list}{ $self->{state} }{repeat} <= 0)
198             {
199 1         2 $self->{previous_state} = $self->{state};
200 1   33     6 $self->{state} = $self->{states_list}{ $self->{state} }{expect}{not_matching0} // $self->{states_list}{ $self->{state} }{not_matching0};
201 1 50       2 if (exists $self->{states_list}{ $self->{previous_state} }{not_matching_info_last}) {
202 1         3 $output = $self->{states_list}{ $self->{previous_state} }{not_matching_info_last};
203             }
204 1   50     4 $output .= $self->{states_list}{ $self->{state} }{output} // '';
205 1   50     11 return ($self->{states_list}{ $self->{state} }{final} // 0, $output);
206             }
207 40014 50       71238 if (exists $self->{states_list}{ $self->{state} }{expect}) {
208 40014 50       61286 if (exists $self->{states_list}{ $self->{state} }{info}) {
209             $output =
210 0         0 $self->{states_list}{ $self->{state} }{info} . $output;
211             }
212 40014 50       56640 if (exists $self->{states_list}{ $self->{state} }{info_once}) {
213 0         0 $output = delete($self->{states_list}{ $self->{state} }{info_once}) . $output;
214             }
215 40014         37996 my $state;
216 40014 50 66     88619 if ( exists $self->{previous_output}
      33        
217             && $in eq ''
218             && $self->{previous_output} =~ /\[(.+)\]/)
219             {
220 0         0 $in = $1;
221             }
222 40014 100       64875 if (exists $self->{states_list}{ $self->{state} }{expect}{$in}) {
223 8         19 $state = $self->{states_list}{ $self->{state} }{expect}{$in};
224             } else {
225 40006         39301 foreach my $key (keys %{ $self->{states_list}{ $self->{state} }{expect} }) {
  40006         93257  
226 160015 100       1026301 if ($in =~ /$key/) {
227             $state =
228 40003         82766 $self->{states_list}{ $self->{state} }{expect}{$key};
229             }
230             }
231             }
232 40014 100       78237 if (ref $state eq 'HASH') {
233 40011         51629 $self->{previous_state} = $self->{state};
234 40011   33     115167 $self->{previous_output} = $state->{output} // $self->{states_list}{ $self->{state} }{output} // '';
      50        
235 40011   66     76349 $self->{state} = $state->{matching} // $self->{state};
236 40011   66     109348 $output .= $state->{output} // $self->{states_list}{ $self->{state} }{output} // '';
      100        
237 40011 50       58408 if (exists $state->{cmd}) {
238 0         0 my $cmd_state = delete $state->{cmd};
239 0         0 $cmd_state =~ s/\$in/$in/g;
240 0         0 push(@{ $self->{cmd_stack} }, $cmd_state);
  0         0  
241             }
242 40011 50       51000 if (exists $state->{cmd_exec}) {
243 0         0 my $cmd_exec = join ' ', @{ $self->{cmd_stack} };
  0         0  
244 0         0 my $string = `$cmd_exec`;
245 0         0 $output = sprintf("%s", $string) . $output;
246 0         0 $self->{cmd_exec} = [];
247             }
248 40011 50       51941 if (exists $state->{exec}) {
249 0         0 my $old_exec = $state->{exec};
250 0         0 $state->{exec} =~ s/__IN__/$in/g;
251 0         0 my $string = `$state->{exec}`;
252 0         0 $output = sprintf("%s", $string) . $output;
253 0         0 $state->{exec} = $old_exec;
254             }
255 40011 50       50248 if (exists $state->{do}) {
256 0         0 my $old_do = $state->{do};
257 0         0 $state->{do} =~ s/__IN__/$in/g;
258 0         0 $output = (eval $state->{do}) . $output;
259 0         0 $state->{do} = $old_do;
260             }
261 40011 100       49909 if (exists $state->{cat}) {
262 1         2 my $old_cat = $state->{cat};
263 1         3 $state->{cat} =~ s/__IN__/$in/g;
264 1         2 my $string = do { local (@ARGV, $/) = $state->{cat}; <> };
  1         4  
  1         58  
265 1         6 $output = sprintf("%s", $string) . $output;
266 1         2 $state->{cat} = $old_cat;
267             }
268 40011 100       49214 if (exists $state->{catRAND}) {
269 20000         20573 my $old_cat = $state->{catRAND};
270 20000         27880 $state->{catRAND} =~ s/__IN__/$in/g;
271 20000         68184 my @files = split /\s+/, $state->{catRAND};
272 20000         41283 my $file = $files[ rand @files ];
273 20000         21579 my $string = do { local (@ARGV, $/) = $file; <> };
  20000         63694  
  20000         905649  
274 20000         70107 $output = sprintf("%s", $string) . $output;
275 20000         42409 $state->{catRAND} = $old_cat;
276             }
277 40011 100       59316 if (exists $state->{catWRAND}) {
278 20000         22318 my $old_cat = $state->{catWRAND};
279 20000         28153 $state->{catWRAND} =~ s/__IN__/$in/g;
280 20000         71012 my %files = map { split /:/} split /\s+/, $state->{catWRAND};
  40000         90639  
281 20000         29674 my $file ;
282             my $weight;
283 20000         45336 while ( my ( $p, $w ) = each %files ) {
284 40000   50     53849 $w //=1;
285 40000   50     61862 $weight += $w//1;
286 40000 100       106364 $file = $p if rand($weight) < $w;
287             }
288 20000         22096 my $string = do { local (@ARGV, $/) = $file; <> };
  20000         63289  
  20000         925590  
289 20000         72726 $output = sprintf("%s", $string) . $output;
290 20000         51533 $state->{catWRAND} = $old_cat;
291             }
292 40011 100       64856 if (exists $state->{catSEQ}) {
293 3         4 my $old_cat =$state->{catSEQ};
294 3         4 my $state_file;
295 3 50       5 if (exists $state->{catSEQ_idx}) {
296             $state_file= $state->{catSEQ_idx}
297 3         5 }else{
298 0         0 $state_file = $old_cat . '.state';
299 0         0 $state_file =~ s/\s/_/g;
300              
301             }
302 3         6 $state->{catSEQ} =~ s/__IN__/$in/g;
303 3         14 my @files = split /\s+/, $state->{catSEQ};
304 3         14 tie my $nbr => 'FSM::Basic::Modulo', scalar @files, 0;
305 3 100       53 if (-f $state_file) {
306 2         4 $nbr = do {
307 2         11 local (@ARGV, $/) = $state_file;
308 2         94 <>;
309             };
310             }
311 3         9 my $file = $files[ $nbr++ ];
312 3         4 my $string = do { local (@ARGV, $/) = $file; <> };
  3         10  
  3         149  
313 3         13 $output = sprintf("%s", $string) . $output;
314 3         6 $state->{catSEQ} = $old_cat;
315 3         5 write_file($state_file, $nbr);
316             }
317             } else {
318 3         7 $self->{previous_state} = $self->{state};
319 3   33     9 $self->{state} = $self->{states_list}{ $self->{state} }{not_matching} // $self->{state};
320             $self->{states_list}{ $self->{state} }{repeat}--
321 3 50       11 if exists $self->{states_list}{ $self->{state} }{repeat};
322 3   50     9 $output .= $self->{states_list}{ $self->{state} }{output} // '';
323 3 50       8 if (exists $self->{states_list}{ $self->{state} }{not_matching_info}) {
324 0         0 $output = $self->{states_list}{ $self->{state} }{not_matching_info} . "\n" . $output;
325             }
326 3   33     60 return ($self->{states_list}{ $self->{state} }{$in}{final} // $self->{states_list}{ $self->{state} }{final} // 0, $output);
      50        
327             }
328 40011   66     236419 return ($self->{states_list}{ $self->{state} }{$in}{final} // $self->{states_list}{ $self->{state} }{final} // 0, $output);
      100        
329             }
330             }
331             }
332              
333             sub set {
334 0     0 0 0 my ($self, $in) = @_;
335 0         0 $self->{previous_state} = $self->{state};
336 0   0     0 $self->{previous_output} = $self->{states_list}{ $self->{state} }{output} // '';
337 0 0       0 $self->{state} = $in if exists $self->{states_list}{$in};
338             }
339              
340             sub write_file {
341 3     3 0 8 my ($file, $content) = @_;
342 3 50       14359 open my $fh, '>' , $file or die "Error opening file for write $file: $!\n";
343 3         33 print $fh $content;
344 3 50       188 close $fh or die "Error closing file $file: $!\n";
345             }
346              
347              
348              
349              
350             package FSM::Basic::Modulo;
351 3   50 3   16 sub TIESCALAR { bless [ $_[2] || 0, $_[1] ] => $_[0] }
352 6     6   6 sub FETCH { ${ $_[0] }[0] }
  6         20  
353 5     5   8 sub STORE { ${ $_[0] }[0] = $_[1] % ${ $_[0] }[1] }
  5         11  
  5         8  
354             1;
355              
356             =head1 EXAMPLE
357              
358              
359             use strict;
360             use feature qw( say );
361             use FSM::Basic;
362             use JSON;
363             use Term::ReadLine;
364              
365             my %states = (
366             'accept' => {
367             'expect' => {
368             'default' => {
369             'final' => 0,
370             'matching' => 'prompt'
371             }
372             },
373             'not_matching' => 'accept',
374             'not_matching0' => 'close',
375             'not_matching_info_last' => '% Bad passwords
376             ',
377             'output' => 'Password: ',
378             'repeat' => 2
379             },
380              
381             'close' => {'final' => 1},
382             'prompt' => {
383             'expect' => {
384             'not_matching' => 'prompt',
385             'exit' => {
386             'matching' => 'close',
387             'final' => 0
388             },
389             "read" => {'cat' => 'file.txt'},
390             "read_random" => {'catRAND' => 'file1.txt file2.txt file3.txt'},
391             "read_seq" => {'catSEQ' => 'file1.txt file2.txt file3.txt', 'catSEQ_idx' => 'catSEQ_status'},
392             'meminfo' => {'do' => 'do { local( @ARGV, $/ ) = "/proc/meminfo" ; <> }'},
393             'mem' => {
394             'do' => "my ( $tot,$avail) = (split /\n/ ,do { local( @ARGV, $/ ) = \"/proc/meminfo\" ; <> })[0,2];$tot =~ s/\\D*//g; $avail =~ s/\\D*//g; sprintf \"%0.2f%%\\n\",(100*($tot-$avail)/$tot);"
395             },
396             'h(elp)?|\\?' => {
397             'output' => 'exit
398             read
399             read_random
400             read_seq
401             meminfo
402             mem_usage
403             mem
404             User> '
405             },
406             'mem_usage' => {'do' => 'my ( $tot,$avail) = (split /\\n/ ,do { local( @ARGV, $/ ) = "/proc/meminfo" ; <> })[0,2];$tot =~ s/\\D*//g; $avail =~ s/\\D*//g; sprintf "%0.2f%%\\n",(100*($tot-$avail)/$tot); '},
407             },
408             'not_matching_info' => '% Unknown command or computer name, or unable to find computer address',
409             'output' => 'User> '
410             }
411             );
412             my $history_file = glob( '/tmp/fsm.history' );
413             my $prompt = '> ';
414             my $line;
415             my $final = 0;
416             my $term = new Term::ReadLine 'bash';
417             my $attribs = $term->Attribs->ornaments( 0 );
418             $term->using_history();
419             $term->read_history( $history_file );
420             $term->clear_signals();
421              
422             my $fsm = FSM::Basic->new( \%states, 'accept' );
423             my $out = "Password> ";
424             while ( defined( $line = $term->readline( $out ) ) )
425             {
426             ( $final, $out ) = $fsm->run( $line );
427             $term->write_history( $history_file );
428             last if $final;
429             }
430              
431             print $out if $final;
432              
433              
434             More sample code in the examples folder.
435              
436              
437             =head1 TODO
438              
439             add "edit" to allow on the fly modification of the states definition
440              
441             add "verify_states" to check all states are reachable from a original state
442              
443              
444              
445             =head1 AUTHOR
446              
447             DULAUNOY Fabrice, C<< >>
448              
449             =head1 BUGS
450              
451             Please report any bugs or feature requests to C, or through
452             the web interface at L. I will be notified, and then you'll
453             automatically be notified of progress on your bug as I make changes.
454              
455             =head1 SUPPORT
456              
457             You can find documentation for this module with the perldoc command.
458              
459             perldoc FSM::Basic
460              
461              
462             You can also look for information at:
463              
464             =over 4
465              
466             =item * RT: CPAN's request tracker (report bugs here)
467              
468             L
469              
470             =item * AnnoCPAN: Annotated CPAN documentation
471              
472             L
473              
474             =item * CPAN Ratings
475              
476             L
477              
478             =item * Search CPAN
479              
480             L
481              
482             =back
483              
484              
485             =head1 ACKNOWLEDGEMENTS
486              
487              
488             =head1 LICENSE AND COPYRIGHT
489              
490             Copyright 2016 DULAUNOY Fabrice.
491              
492             This program is free software; you can redistribute it and/or modify it
493             under the terms of the the Artistic License (2.0). You may obtain a
494             copy of the full license at:
495              
496             L
497              
498             Any use, modification, and distribution of the Standard or Modified
499             Versions is governed by this Artistic License. By using, modifying or
500             distributing the Package, you accept this license. Do not use, modify,
501             or distribute the Package, if you do not accept this license.
502              
503             If your Modified Version has been derived from a Modified Version made
504             by someone other than you, you are nevertheless required to ensure that
505             your Modified Version complies with the requirements of this license.
506              
507             This license does not grant you the right to use any trademark, service
508             mark, tradename, or logo of the Copyright Holder.
509              
510             This license includes the non-exclusive, worldwide, free-of-charge
511             patent license to make, have made, use, offer to sell, sell, import and
512             otherwise transfer the Package with respect to any patent claims
513             licensable by the Copyright Holder that are necessarily infringed by the
514             Package. If you institute patent litigation (including a cross-claim or
515             counterclaim) against any party alleging that the Package constitutes
516             direct or contributory patent infringement, then this Artistic License
517             to you shall terminate on the date that such litigation is filed.
518              
519             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
520             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
521             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
522             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
523             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
524             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
525             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
526             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
527              
528              
529             =cut
530              
531             1; # End of FSM::Basic