Skip to content

Commit d3be3ef

Browse files
committed
feat(config): static interpolating variables (variables {})
A new optional variables {} config section defines NAME = value pairs that menu/form authors reference as $NAME / ${NAME} to cut duplication. Values may reference each other; cross-references are resolved once at config load (a few fixpoint passes; undefined names left verbatim). In menu/form actions and field list_cmd commands, expand_vars() substitutes the defined names before the command runs (at the choke points exec_command / call_system / run_browse / the final exec); any other $... (e.g. $HOME) is left for the shell. CCFE-side substitution, not env export -- so a value may contain shell metacharacters and nothing clobbers PATH/HOME. The variables are as trusted as the menus/forms; under restricted mode config is system-locked and the allow-list still checks the command as written (a bare $VAR program name is refused unless allow-listed). Documented in ccfe.conf(5). Demo for testing: ccfe.conf ships a variables {} block (DEMO_NAME, DEMO_HELLO referencing it, LIST_DIR=/etc) and a new demo menu entry "Config variables demo" (demo.d/vars.form) whose list_cmd lists $LIST_DIR and whose action greets via $DEMO_HELLO. Covered by t/34 (cross-ref in a run: action, $HOME shell pass-through, a variable in a list_cmd). Full suite green (378); ccfe.pl tidy+critic gates pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a5dd6ae commit d3be3ef

6 files changed

Lines changed: 239 additions & 4 deletions

File tree

src/ccfe.conf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,14 @@ active_field_attr {
7373
changed_value_fg = A_NORMAL
7474
changed_value_bg = A_REVERSE
7575
}
76+
77+
# User-defined variables (see ccfe.conf(5) "VARIABLES CONFIGURATION SECTION").
78+
# Define a value once and reference it as $NAME / ${NAME} in menu and form
79+
# actions and in a field's list_cmd. Variables may reference each other; the
80+
# references are resolved when the configuration is loaded. Used by the
81+
# "Config variables demo" form (demo.d/vars.form).
82+
variables {
83+
DEMO_NAME = CCFE
84+
DEMO_HELLO = Hello from ${DEMO_NAME} configuration variables!
85+
LIST_DIR = /etc
86+
}

src/ccfe.pl

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -820,8 +820,25 @@ sub load_msgs {
820820
close(INF);
821821
}
822822

823+
# Substitute $NAME / ${NAME} references to config `variables {}` in a command or
824+
# action string with their resolved values. Only names defined in the section
825+
# are replaced; any other $... is left untouched for the shell. The variables
826+
# are system-config-owned (and config-locked under RESTRICTED), so this is a DRY
827+
# convenience for menu/form authors, not an untrusted-input path. Applied at
828+
# the command choke points (exec_command / call_system / run_browse / the final
829+
# exec). (FEATURE-REQUESTS item 3.)
830+
sub expand_vars {
831+
my ($str) = @_;
832+
return $str unless defined $str;
833+
my $vars = $ctx->{cfg}{vars} or return $str;
834+
$str =~ s/\$\{(\w+)\}/ exists $vars->{$1} ? $vars->{$1} : "\${$1}" /ge;
835+
$str =~ s/\$(\w+)/ exists $vars->{$1} ? $vars->{$1} : "\$$1" /ge;
836+
return $str;
837+
}
838+
823839
sub exec_command {
824840
my ( $cmd, $extra_path, $stdout_ref, $stderr_ref ) = @_;
841+
$cmd = expand_vars($cmd);
825842

826843
my ( $prev_path, $prev_wdir );
827844

@@ -1015,6 +1032,7 @@ sub call_shell {
10151032

10161033
sub call_system {
10171034
my ( $wait_key, $cmd ) = @_;
1035+
$cmd = expand_vars($cmd);
10181036

10191037
my ($res);
10201038
def_prog_mode();
@@ -2167,6 +2185,27 @@ sub load_config {
21672185
}
21682186
last SWITCH;
21692187
}
2188+
elsif (/^VARIABLES$/) {
2189+
2190+
# User-defined config variables (FEATURE-REQUESTS
2191+
# item 3): `NAME = value` lines, collected raw here
2192+
# and resolved (cross-references expanded) once after
2193+
# all config files are read. Names are kept
2194+
# case-sensitively (they are referenced as $NAME in
2195+
# actions); a value may be optionally quoted.
2196+
foreach my $line ( split /\s*\n\s*/, $val ) {
2197+
next if $line =~ /^\s*$/;
2198+
my ( $vn, $vv ) = split /\s*=\s*/, $line, 2;
2199+
next
2200+
unless defined $vn and $vn =~ /^\w+$/;
2201+
$vv //= '';
2202+
$vv =~ s/^\s+//;
2203+
$vv =~ s/\s+$//;
2204+
$vv =~ s/^(['"])(.*)\1$/$2/;
2205+
$ctx->{cfg}{vars}{$vn} = $vv;
2206+
}
2207+
last SWITCH;
2208+
}
21702209
else {
21712210
trace("unknown configuration parameter \"$key\"");
21722211
$res = $ES_SYNTAX_ERR;
@@ -2185,6 +2224,31 @@ sub load_config {
21852224
$res = $ES_NOT_FOUND;
21862225
}
21872226
}
2227+
2228+
# All config files read: resolve cross-references between user variables
2229+
# ($NAME / ${NAME}) with a few fixpoint passes, so each variable's stored
2230+
# value is fully expanded before actions reference it. A reference to an
2231+
# undefined name is left verbatim; a circular reference stops at the pass
2232+
# cap (and simply stays unresolved rather than looping). (FEATURE-REQUESTS
2233+
# item 3.)
2234+
if ( my $vars = $ctx->{cfg}{vars} ) {
2235+
for ( 1 .. 10 ) {
2236+
my $changed = 0;
2237+
for my $k ( keys %{$vars} ) {
2238+
my $new = $vars->{$k};
2239+
$new =~
2240+
s/\$\{(\w+)\}/ exists $vars->{$1} ? $vars->{$1} : "\${$1}" /ge;
2241+
$new =~
2242+
s/\$(\w+)/ exists $vars->{$1} ? $vars->{$1} : "\$$1" /ge;
2243+
$changed = 1 if $new ne $vars->{$k};
2244+
$vars->{$k} = $new;
2245+
}
2246+
last unless $changed;
2247+
}
2248+
trace( "config variable $_ = \"$vars->{$_}\"", $LOG_NORMAL )
2249+
for sort keys %{$vars};
2250+
}
2251+
21882252
return $res;
21892253
}
21902254

@@ -4726,6 +4790,7 @@ ( $cmd, $mwin )
47264790

47274791
sub run_browse {
47284792
my ( $title, $cmd, $save_fname, $extra_path ) = @_;
4793+
$cmd = expand_vars($cmd);
47294794

47304795
local ($search_string);
47314796
my ( $out_lines, $err_lines );
@@ -5677,11 +5742,12 @@ sub VERSION_MESSAGE {
56775742
endwin();
56785743
system("clear") if $es == $ES_NO_ERR or $es >= $ES_USER_REQ;
56795744
if ( defined( $ctx->{state}{exec_args} ) ) {
5745+
my $exec_args = expand_vars( $ctx->{state}{exec_args} );
56805746
chdir "$ctx->{state}{SCREEN_DIR}";
56815747
trace( "Changed CWD from $prev_wdir to " . getcwd() );
5682-
trace("exec \"$ctx->{state}{exec_args}\"");
5748+
trace("exec \"$exec_args\"");
56835749
if ( $ctx->{cfg}{RESTRICTED} ) {
5684-
my @argv = shellwords( $ctx->{state}{exec_args} ); # TD-1c
5750+
my @argv = shellwords($exec_args); # TD-1c
56855751
exec { $argv[0] } @argv if @argv;
56865752
}
56875753

@@ -5690,9 +5756,9 @@ sub VERSION_MESSAGE {
56905756
# explanation (FEATURE-REQUESTS item 6; the pre-flight modal catches the
56915757
# common case while the TUI is still up). The `or do` keeps the failure
56925758
# path in the same statement (no "unreachable code after exec" warning).
5693-
exec( $ctx->{state}{exec_args} ) or do {
5759+
exec($exec_args) or do {
56945760
my $err = $!;
5695-
my ($prog) = shellwords( $ctx->{state}{exec_args} );
5761+
my ($prog) = shellwords($exec_args);
56965762
print STDERR "$CALLNAME: cannot execute \"$prog\": $err\n";
56975763
exit 127;
56985764
};

src/demo.d/vars.form

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#
2+
# CCFE demo: static interpolating config variables.
3+
# The list_cmd and the action below reference variables defined in the
4+
# variables {} section of ccfe.conf ($DEMO_HELLO, $LIST_DIR). %{FILE} is the
5+
# usual form-field substitution; $... names a config variable.
6+
#
7+
title {
8+
Config variables demo
9+
}
10+
top {
11+
Press <F2> on the field to list files (the list_cmd uses $LIST_DIR),
12+
pick one, then <Enter> -- the action greets you using $DEMO_HELLO.
13+
}
14+
field {
15+
id = FILE
16+
label = A file from the configured directory (F2 lists it)
17+
len = 36
18+
list_cmd = command:single-val:ls $LIST_DIR
19+
}
20+
action { run:
21+
echo "$DEMO_HELLO"
22+
echo "You picked: %{FILE}"
23+
}

src/demo.menu/vars.item

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
item {
2+
id = VARS
3+
descr = Config variables demo (uses ccfe.conf variables {})
4+
action = form:demo.d/vars
5+
}

src/man/ccfe.conf.5

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,57 @@ Example:
396396
Required: No
397397

398398

399+
.SH VARIABLES CONFIGURATION SECTION
400+
The optional
401+
.B variables {}
402+
block defines named values that menu and form authors can reference, to avoid
403+
repeating the same path or command fragment across many objects. Each line is a
404+
.B NAME = value
405+
pair; the value may be optionally quoted and may reference other variables with
406+
.BR $NAME " or " ${NAME} \fR.
407+
Inter-variable references are resolved once, when the configuration is loaded
408+
(so order does not matter), and a reference to an undefined name is left
409+
verbatim.
410+
.PP
411+
In a menu or form
412+
.BR action " and in a field's " list_cmd
413+
command, every
414+
.BR $NAME / ${NAME}
415+
that names a defined variable is replaced with its value before the command
416+
runs; any other
417+
.B $...
418+
(for example
419+
.BR $HOME )
420+
is left untouched for the shell. Variable names are case-sensitive and consist
421+
of letters, digits and underscores.
422+
.PP
423+
Example:
424+
.PP
425+
.nf
426+
\fC variables {
427+
DOCKER_DIR = /srv/docker
428+
COMPOSE = cd $DOCKER_DIR && docker compose
429+
}\fP
430+
.fi
431+
.PP
432+
A menu item could then use
433+
.BR "action = run:$COMPOSE up -d" \fR,
434+
which runs
435+
.BR "cd /srv/docker && docker compose up -d" \fR.
436+
.PP
437+
.B Security note:
438+
the substitution writes the variable's value into the command string, so the
439+
variables are as trusted as the menus and forms themselves. Under
440+
.B restricted
441+
mode the configuration is locked to system-owned files, so unprivileged users
442+
cannot define or change them; and the restricted command allow-list is still
443+
checked against the command as written, so a bare
444+
.B $VAR
445+
program name is refused unless allow-listed.
446+
.PP
447+
Required: No
448+
449+
399450
.SH FORM CONFIGURATION SECTION
400451
The
401452
.B form_global {}

src/t/34-config-vars.t

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/perl
2+
#
3+
# Static interpolating config variables (FEATURE-REQUESTS item 3).
4+
#
5+
# A `variables { }` config section defines NAME = value pairs that may reference
6+
# each other ($NAME / ${NAME}); the references are resolved once at config load.
7+
# In menu/form actions and list_cmd commands, $NAME is then substituted with the
8+
# resolved value (only defined names; any other $... is left for the shell).
9+
# This drives all three: a cross-referenced variable in a run: action, the
10+
# pass-through of an undefined $VAR to the shell, and a variable in a list_cmd.
11+
#
12+
use strict;
13+
use warnings;
14+
use FindBin qw($Bin);
15+
use lib "$Bin/lib";
16+
use File::Temp qw(tempdir);
17+
use Test::More;
18+
19+
my $src = "$Bin/..";
20+
21+
eval { require CCFE::Test::Pty; 1 } or plan skip_all => "pty helper: $@";
22+
plan skip_all => 'no Linux pseudo-terminal' unless CCFE::Test::Pty->available;
23+
plan skip_all => 'Curses not installed' unless eval { require Curses; 1 };
24+
plan skip_all => 'no installer' unless -f "$src/install.sh";
25+
26+
my $prefix = tempdir( CLEANUP => 1 );
27+
my $log = `cd "$src" && sh install.sh -b -p "$prefix" 2>&1`;
28+
plan skip_all => "install failed: $log" unless $? == 0 && -x "$prefix/bin/ccfe";
29+
30+
# Append a variables{} section with a cross-reference to the system config.
31+
my $conf = "$prefix/etc/ccfe.conf";
32+
open( my $cf, '>>', $conf ) or plan skip_all => "conf: $!";
33+
print {$cf} "\nvariables {\n BASE_DIR = /tmp\n"
34+
. " MSG = captured from \${BASE_DIR}\n}\n";
35+
close($cf);
36+
37+
my $objs = "$prefix/share/ccfe/objects/ccfe";
38+
open( my $fh, '>', "$objs/vars.form" ) or plan skip_all => "write: $!";
39+
print {$fh} <<'FORM';
40+
title { Vars test }
41+
field {
42+
id = PICK
43+
label = Pick
44+
len = 24
45+
list_cmd = command:single-val:echo $MSG
46+
}
47+
action { run:echo "$MSG ; home=$HOME" }
48+
FORM
49+
close($fh);
50+
51+
plan tests => 3;
52+
53+
# --- run: action: cross-referenced variable + shell pass-through ----------
54+
my $p = CCFE::Test::Pty->spawn( 80, 24, "$prefix/bin/ccfe", 'vars' );
55+
$p->pump(1.2);
56+
$p->send("\r"); # submit -> run -> output browser
57+
$p->pump(1.3);
58+
my $out = $p->screen;
59+
like( $out, qr{captured from /tmp},
60+
'a cross-referenced $\{VAR} resolves in a run: action' );
61+
like( $out, qr{home=/\w},
62+
' an undefined $VAR is left for the shell to expand ($HOME)' );
63+
$p->send("\e");
64+
$p->pump(0.2);
65+
$p->send("\e");
66+
$p->pump(0.2);
67+
68+
# --- list_cmd command: a variable resolves there too (exec_command) -------
69+
my $f = CCFE::Test::Pty->spawn( 80, 24, "$prefix/bin/ccfe", 'vars' );
70+
$f->pump(1.2);
71+
$f->send("\eOQ"); # F2 = list
72+
$f->pump(0.8);
73+
like( $f->screen, qr{captured from /tmp},
74+
'a variable resolves in a list_cmd command' );
75+
$f->send("\e");
76+
$f->pump(0.2);
77+
$f->send("\e");
78+
$f->pump(0.2);
79+
$f->send("\e");

0 commit comments

Comments
 (0)