Subject: src/ccfe.pl — single-file Perl/Curses TUI (≈4.6k lines).
Date: 2026-06-09 (recommendations refreshed after the v1.60 work).
Goal: keep CCFE on standard packages only (Perl core + libcurses-perl)
and the .menu/.form/.item plugin contract intact, while making it safer to
deploy, easier to maintain, and nicer to use.
Already shipped in v1.60 (see the git log for the detail):
- The historical "segfault on forms"/startup crash is fixed — it was a Perl lifetime bug in how the menu/form item arrays were handed to ncurses, plus an empty-menu guard. No static recompilation was ever needed.
- The custom GCC/ncurses build apparatus was retired in favour of a slim
Debian image; the runtime dependency is now just
perl+libcurses-perl. - A
Test::Moresuite was added: compile check, plugin-format parser conformance, a source-level regression guard, and a pty-driven tty smoke test (t/), all core-only.
Also now delivered (post-v1.60 — see the git log and the section noted):
- Security / restricted mode (§2) — opt-in
restricted = yesdisables the F7 shell escape, the runnable-script save, and gatessystem:/exec:behind an allowlist; env hardening +CCFE_FIELD_*exposure are always on. lib/CCFE/foundation (§3) — the package tree exists; the security policy (CCFE::Restrict) and colour (CCFE::Theme) are extracted as pure, modern-Perl (use v5.36) modules, loaded via an__FILE__-relativeuse lib, shipped by the installer, and unit-tested. The legacyrequire 5.8.0floor is gone.- Quality gates (§4) —
.perlcriticrc,.perltidyrc, aMakefile(make check) and a GitHub Actions CI workflow, enforced on the new modules. - Optional colour (§5) —
CCFE::Themepre-creates the standard colour pairs so any*_attrcan useCOLOR_PAIR(n). The whole UI is themable: menus (screen_attr/item_attr/selected_attr), the header (title_attr) and the control keys (key_attr). Two themes ship:ccfe.conf.smit(classic monochrome) andccfe.conf.smit-color. Monochrome fallback intact. Still open: a full fg/bg palette section (background colours / panelled look). -k NAMElinter (§6.4) — headless parse-check of a menu/form for authors and CI.
The test suite is now 105 tests (make check), still core + libcurses-perl
only. What remains is the bulk of the §3 de-globalisation of the legacy
single file, and the larger §6 items below (wide-char/UTF-8, full resize
reflow, runtime-config instead of sed templating, packaging).
CCFE's main program remains a single 4.6k-line script with no
use strict/use warnings and ~200 lines of global state; the parsing layer
(load_menu/load_form) is already curses-free, which is the seam the
remaining restructure builds on.
CCFE is frequently deployed as a constrained front-end: a kiosk, an operator console, or a restricted login (symlink the binary to a name and give an account that menu tree as its shell). In that role the security goal is simple — a menu user must only be able to do what the menus allow — but the current code has several ways out. Treat menu/form/config files as trusted (the administrator authors them) and everything the end-user types as untrusted. The gaps below all break that boundary.
| # | Vector | Where | Risk |
|---|---|---|---|
| E1 | Shell-escape key (F7) drops to a full interactive shell: system("PS1=… $USER_SHELL") |
call_shell ≈:659, bound in @MSKeys/@FSKeys/@RSKeys |
Total escape — the user gets a shell. Gated only by valid_shell(). |
| E2 | Command injection via field values. A field's value is substituted raw into a command string that is then run by sh -c: $$action_ref =~ s/%\{$id\}/$val/g then open3(…, $OPEN3_SHELL, '-c', $cmd) |
subst ≈:2842; exec ≈:510,:3956; system($cmd) ≈:674 |
A field value of ; sh, $(…), or `…` runs arbitrary commands even inside an otherwise locked-down menu. |
| E3 | exec: / system: verbs run arbitrary commands by design; the final exec($exec_args) replaces CCFE with a shell-parsed string |
:4636, dispatch in do_menu/do_form |
Any menu/form that uses these (or a user-editable one) is an exit. |
| E4 | Save-to-script writes a runnable #!$OPEN3_SHELL script into $HOME |
:4225 |
Combined with E1/E3, a way to stage and run code. |
| E5 | Inherited environment / PATH. PATH is rebuilt from $MAIN_PATH:$PATH; IFS, LD_*, etc. are inherited |
call_system ≈:670 |
Untrusted env can redirect which binaries run:/system: resolve. |
-
Add an explicit "restricted" policy (opt-in lockdown). A single
RESTRICTEDswitch in.conf(and/or keyed off the call-name, so a symlinkedkioskbinary is locked whileccfeis not) that, when on:- removes
shell_escapefrom@MSKeys/@FSKeys/@RSKeys(no F7), - disables
save-to-script, - refuses the
exec:/system:verbs unless the target is on an allowlist, and - optionally confines
run:to an allowlisted set of commands. Centralise this in aCCFE::Restrictpolicy object consulted at every dispatch and key-handling site, so the rule lives in one place.
- removes
-
Never interpolate untrusted input into a shell string (fixes E2). Stop building
sh -c "$cmd"out of%{FIELD}substitutions. Instead:- run actions with
system/open3LIST form (@argv), so each field value is a distinct argument the shell never re-parses; or - export field values as environment variables (
CCFE_FIELD_<ID>) that the command reads, rather than concatenating them into a command line. Where templating into a single string is genuinely required, shell-quote by default with a small core helper (single-quote + escape) — opt-out, not opt-in. This is the highest-value change: it closes injection even for menus that are meant to run commands.
- run actions with
-
Harden the trusted edges. Sanitise the environment before any exec (reset
IFS, dropLD_*/BASH_ENV/ENV, set an absolutePATHfrom config), keep the existingumask 0077, and let the administrator pinUSER_SHELL/OPEN3_SHELLin config and forbid user override (config precedence). Validate the shell against/etc/shellsas well asvalid_shell(). -
Audit trail. In restricted mode, log every command actually executed (CCFE already has
trace(); route it to an append-only, admin-owned file) so deployments have accountability for what ran. -
Make the trust boundary explicit in code and docs. A short "Security & trust model" section in the man page and README: who may edit menus vs. who merely uses them, and the guarantee restricted mode does (and does not) provide. Add tests that assert F7/
exec:/saveare inert underRESTRICTED=1.
These are defensive measures for operators who want to constrain a menu. They don't make CCFE a security boundary on their own (a determined local user has many avenues); pair restricted mode with OS-level controls (a real restricted shell, containerisation, or seccomp/AppArmor) for untrusted users.
The single file is the main maintainability cost. The target shape is the "functional core, imperative shell" pattern: pure, terminal-free logic that is trivial to unit-test, wrapped by a thin layer that does curses I/O and runs commands.
bin/ccfe (thin entry point) + lib/CCFE/:
| Module | Responsibility | Purity |
|---|---|---|
CCFE::Config |
.conf parsing, paths, constants, defaults |
pure |
CCFE::MenuFile |
.menu / .item parsing (the Text::Balanced parser) |
pure (already curses-free) |
CCFE::FormFile |
.form parsing |
pure |
CCFE::Action |
resolve menu:/form:/run:/system:/exec: + modifiers |
pure |
CCFE::Layout |
window geometry, pagination, column maths | pure (extract from do_menu/do_form) |
CCFE::Exec |
exec_command/call_system/dispatch (the effectful edge) |
effectful |
CCFE::Restrict |
the §2 security policy | pure decisions |
CCFE::Theme |
attribute/colour mapping (the §5 colour work) | pure |
CCFE::UI::Menu, ::Form, ::List |
the curses widgets / event loops | effectful |
Keep the installer's path templating working (or, better, drop the sed
templating and read paths from config at runtime — see §6).
- Separate pure from effectful. Parsing and action-resolution are already
nearly pure (the headless parser tests prove it). Push the remaining global
reads/writes out of them so
MenuFile/FormFile/Action/Layoutare referentially transparent and unit-testable with no terminal. - Return immutable data, don't mutate globals.
load_menucurrently fills package globals (%menu,@mf_pathside effects); have parsers return a data structure the caller owns. - Replace globals and
localdynamic scope with an explicit$ctx. The ~200 lines of globals (:43-206,:4509-4545) and thelocal %form/@fp/…threaded into nested subs (do_form) become one state object passed explicitly. This removes the spookiest action-at-a-distance in the code. - Pure layout helpers. Pagination, scaling and column maths are inline in the event loops; extract them as pure functions and unit-test the edge cases (1-item menus, over-long labels, narrow terminals) that have historically bitten CCFE.
- Thin event loops. The
do_menu/do_form/do_listloops become small imperative shells calling the pure helpers, which is also whereKEY_RESIZEreflow (started in v1.60) belongs.
The ccfe-plugin-sysmon plugin is the conformance fixture throughout: the
restructure is "done" only when it installs and runs unchanged and the parser
tests still pass against it.
Add the standard Perl quality tooling (all available as Debian packages — no CPAN required) and wire it into CI so regressions are caught mechanically.
use strict; use warnings;in every new module — the single biggest correctness win, andperlcritic's first rule. Introduce per-module as the split in §3 lands (turning it on wholesale in the legacy file at once would bury you in fixups).perlcritic(libperl-critic-perl). Add a.perlcriticrcthat starts lenient and tightens over time:- enforce strictly on
lib/CCFE/(target severity 3 "harsh", aiming for 2), - exempt the legacy
ccfe.plinitially (severity 5 "gentle") so it doesn't block work, so the new code is held to a high bar while the old code is migrated.
- enforce strictly on
perltidy(libperl-tidy-perl) with a checked-in.perltidyrcfor consistent formatting; add amake tidy/ pre-commit check. The code is already fairly uniform, so this is low-friction.- CI workflow (GitHub Actions or equivalent) on Debian:
perl -c,prove -lr t/,perlcritic lib/,podchecker src/man/*. The pty tty test skips itself in headless CI automatically, so the suite is green without a TTY. - Coverage (optional):
Devel::Cover(libdevel-cover-perl) to track how much of the pure core the tests exercise — most useful once §3 makes the core testable.
The program was written for Perl 5.8 (the require 5.8.0; floor, now removed —
the runtime is Perl 5.40 on current Debian). New lib/CCFE/ modules already
target modern Perl with use v5.36 (which turns on strict, warnings and
subroutine signatures in one line). The legacy single file can adopt the
same idioms as it is de-globalised (§3); most are exactly what perlcritic
will flag:
| Modern feature | Replaces in CCFE | Note |
|---|---|---|
use v5.36 (strict+warnings+signatures+say) |
no strict/warnings, my ($a,$b)=@_; in nearly every sub |
the prerequisite; gate behind de-globalisation for ccfe.pl |
Subroutine signatures sub f ($a,$b) |
@_ unpacking boilerplate |
arity-checked, self-documenting |
// and //= (defined-or) |
defined($x) ? $x : … (e.g. $ARGV[0] ? … : $REALNAME, field defaults) |
correct for 0/'' |
builtin::trim, true/false, is_bool |
the hand-rolled trim(), the $YES/$NO ints |
core in 5.40 |
builtin::blessed / reftype, the isa operator |
the fragile if ($item eq '') allocation-failure checks on Curses objects |
a real correctness fix |
three-arg lexical open(my $fh,'<',$f) |
two-arg bareword open(INF,$f) / open(OUTF,">$fname") |
strict-clean and avoids mode-injection from odd filenames |
Cwd::getcwd |
backtick `pwd` (≈5 sites, incl. the 'pwd' string typo) |
no shell, faster |
system { $prog } @argv / list-form exec |
system("$cmd") / open3(…, $SHELL,'-c',$cmd) |
no shell parsing — ties into the §2 injection work |
postfix deref $ref->@*, $ref->%* |
@{ $menu{items} }, $#{ $form{fields} } |
readability |
lexical subs my sub |
nested named subs/closures (e.g. inside do_form) |
tighter scope |
Apply opportunistically as each area is modularised; don't rewrite the legacy
file wholesale. perlcritic (§4.2) mechanically surfaces the two-arg opens,
bareword filehandles, string eval, and missing-strict cases.
CCFE is monochrome today: it uses only attribute constants
(A_NORMAL/A_REVERSE/A_BOLD) and never calls start_color(). The good
news is that those attributes are already funnelled through named variables
($menu_fg_attr, $menu_bg_attr, $win_bg_attr, $RS_STDOUT_ATTR,
$RS_STDERR_ATTR, $RS_INFO_ATTR, … around :2471 and :4556), so colour is
a contained, additive change rather than a rewrite.
How to add it:
- After
initscr, guard on capability:if (has_colors()) { start_color(); use_default_colors(); }(use_default_colorslets-1mean "the terminal's own background", which looks right on themed terminals). - Define a small palette of
COLOR_PAIRs for roles — title, menu item, selected item, footer/keys, field label, field value, info, stderr/error — withinit_pair($n, $fg, $bg). - Route the existing
*_attrvariables throughCOLOR_PAIR($n)(OR-able withA_BOLD/A_REVERSEfor emphasis). Because they're already centralised, this is essentially one mapping table inCCFE::Theme. - Make the palette configurable in
.conf(acolors { title=cyan/-, selected=black/cyan, error=red/- }block), so operators can theme a deployed menu without code changes. - Fall back cleanly: when
has_colors()is false, in$LAYOUT == $SIMPLE, whenNO_COLORis set, or with a--no-colorflag, keep today's monochrome attributes exactly. Colour must never be required. - The Debian build links
ncursesw, so 256-colour and default-background are available; theming and a couple of shipped presets (e.g. "classic", "high-contrast") add real polish at low risk.
Keeping it gated and attribute-driven means monochrome terminals and the existing SIMPLE layout are untouched.
Beyond the above, ordered roughly by value-to-effort:
- Wide-char / UTF-8 correctness. Width maths use byte
length()/substr()(e.g. label/column truncation), which mis-aligns non-ASCII menus and forms. Move to display-column counting (coreEncode+ careful column logic, or a small width helper) now thatncurseswis the runtime. Important for internationalised menus and the existingmsg/i18n. - Resize reflow. ✅ Done for menus and forms:
do_menurebuilds its windows/menu anddo_formre-paginates its fields (free_form → move/ set_new_page → new_form, values preserved) and rebuilds its window onKEY_RESIZE. Builds are clamped to 80x24 so a tiny terminal can't crash, and anEND{}block restores the terminal on any die. Still open: a horizontal re-layout of right-aligned field values, and reflow fordo_list/run_browse. - Drop the
sedpath-templating; configure at runtime. ✅ Done (v2):ccfe.plresolves its paths from its own location (FindBin) withCCFE_*_DIRenv overrides, so it installs byte-identical, is relocatable, and packages cleanly. The installer just copies the program. - A
ccfe --check <file>linter and machine-readable--dump. ✅ The linter shipped asccfe -k NAME(parse-check, exit 0/1/2; seet/07-check-cli.t). Still open: a machine-readable--dumpof the parsed structure (JSON/text) for tooling, docs generation and tests. - Versioned plugin manifest. A small
plugin.metadeclaring the CCFE version a plugin targets, so the loader can warn on mismatch — useful as the plugin surface evolves. - Graceful in-curses errors. Replace
fatal()/die-to-raw-terminal with an in-curses error dialog plus a logged diagnostic, so a bad plugin doesn't dump a Perl stack over the screen. - Proper packaging. Ship a Debian package (
dh-make-perl/debhelper) and a CPAN-style dist, so installation isn't a hand-rolledinstall.sh. Pairs naturally with §6.3. - Mouse support (optional).
Cursesexposesmousemask; clickable items and footer keys would modernise the UX without breaking keyboard use. Gate it behind config. - Configurable keybindings & accessibility. Keys are already partly
configurable; expose them fully in
.conf, and add a high-contrast theme (ties into §5) and a documented screen-reader-friendly mode. - Docs refresh & a plugin tutorial. Refresh the man pages, add a
step-by-step "write your first plugin" guide, and document the §2 security
model. Ship one extra
msg/locale as a worked i18n example.
The hotfix and tooling phases are done (v1.60). Remaining work, in dependency order:
- Security (§2). Field-value quoting/argv execution and restricted mode are high-value and can land on the current single-file program before the restructure. Add the inert-under-lockdown tests.
- Modularise (§3). Split into
bin/+lib/CCFE/,strict/warnings, de-globalise to a$ctx, extract the pure layout/parse core. Gate on the sysmon conformance test and the existing parser tests. - Quality gates (§4). Land
perlcritic/perltidy/CI alongside the modular code so the new modules are held to standard from day one. - Colour & UX (§5, §6). Additive, lower-risk polish once the core is
modular and tested: theming, wide-char correctness, resize reflow, the
--check/--dumptooling, packaging.
Net dependency throughout: perl (core) + libcurses-perl. Nothing
else — the "standard packages only" constraint holds at every step.
The extension mechanism is the project's value and must survive every change above unchanged. Contract to keep:
- Discovery:
ccfe <name>resolves<name>.menu/<name>.formalong the search path ($LIBDIR/$CALLNAME,~/.ccfe/$CALLNAME), keyed off the invoked name so symlinked front-ends get their own tree +<name>.conf. - Static menu = a
.menufile; dynamic menu = a.menudirectory ofdefinition+*.itemfiles merged together. - Forms =
.formfiles, optionally grouped in a.ddirectory. .iteminjection — a plugin drops a*.iteminto another menu's directory to graft itself on (howccfe-plugin-sysmonadds itself todemo).- Block syntax via
Text::Balanced::extract_bracketed—title { },top { },item { id=… descr=… action=… },field { … },action { … }. - Action verbs:
menu:/form:/run:/system:/exec:with modifiers(confirm,log,wait_key). list_cmd:command:single-val:…/command:multi-val:…/const:single-val:…/const:multi-val:…with%{FIELD_ID}substitution (whose execution is hardened per §2.2, while the syntax is unchanged).
ccfe-plugin-sysmon/ is the conformance fixture: any refactor is "done" only
when that plugin installs and runs unchanged.