bitslip6/bitfire

View on GitHub
firewall/src/util.php

Summary

Maintainability
F
3 days
Test Coverage
<?php
/**
 * TODO: remove bitfire specific code, refactor into bitfire utils 
 * BitFire PHP based Firewall.
 * Author: BitFire (BitSlip6 company)
 * Distributed under the AGPL license: https://www.gnu.org/licenses/agpl-3.0.en.html
 * Please report issues to: https://github.com/bitslip6/bitfire/issues
 * 
 * all functions are called via api_call() from bitfire.php and all authentication 
 * is done there before calling any of these methods.
 */

namespace ThreadFin;

use const BitFire\ACTION_CLEAN;
use const BitFire\ACTION_RETURN;
use const BitFire\BITFIRE_VER;
use const BitFire\CONFIG_CACHE_TYPE;
use const BitFire\CONFIG_COOKIES;
use const BitFire\CONFIG_ENCRYPT_KEY;
use const BitFire\CONFIG_USER_TRACK_COOKIE;
use const BitFire\FILE_R;
use const BitFire\FILE_RW;
use const BitFire\FILE_W;
use const BitFire\STATUS_OK;
use const BitFire\WAF_ROOT;
use const BitFire\WAF_SRC;

use \BitFire\Config as CFG;
use \BitFire\Block as Block;
use RuntimeException;

use function BitFire\on_err;
use function BitFireSvr\update_ini_value;
use function \ThreadFin\partial as BINDL;
use function \ThreadFin\partial_right as BINDR;

if (defined("BitFire\_TF_UTIL")) { return; }
define("BitFire\_TF_UTIL", 1);


const DS = DIRECTORY_SEPARATOR;
const WEEK=86400*7;
const DAY=86400;
const HOUR=3600;
const MINUTE=60;

const ENCODE_RAW=1;
const ENCODE_SPECIAL=2;
const ENCODE_HTML=3;
const ENCODE_BASE64=4;

require_once WAF_SRC . "const.php";
require_once WAF_SRC . "http.php";

interface Catalog {
    public function add($key, $value);
    public function get_key($key);
    public function get_value($value);
}

class Pair {
    public $key;
    public $value;
}

class List_Fwd_Rev_Pair implements Catalog {
    public $fwd_list = array();
    public $rev_list = array();

    public function add($key, $value) {
        $pair = new Pair();
        $pair->key = $key;
        $pair->value = $value;
        $this->fwd_list[$key] = $pair;
        $this->rev_list[$value] = $pair;
    }

    public function get_key($key) {
        return $this->fwd_list[$key];
    }
    public function get_value($value) {
        return $this->rev_list[$value];
    }
}


/**
 * Complete filesystem abstraction
 * @package ThreadFin
 */
class FileData {
    /** @var string $filename - full path to file on disk */
    public $filename;
    /** @var int $num_lines - number of lines of content */
    public $num_lines;
    /** @var array $lines - file content array of lines */
    public $lines = array();
    public $debug = false;
    public $size = 0;
    public $content = "";
    /** @var bool $exists - true if file or mocked content exists */
    public $exists = false;
    /** @var bool $readable - true if file is readable */
    public $readable = false;
    /** @var bool $readable - true if file is writeable */
    public $writeable = false;

    protected static $fs_data = array();
    protected $errors = array();

    /**
     * mask file system with mocked $content at $filename
     * @param string $filename 
     * @param string $content 
     */
    public static function mask_file(string $filename, string $content) {
        FileData::$fs_data[$filename] = $content;
    }

    /**
     * @return array of any errors that may have occurred
     */
    public function get_errors() : array { return $this->errors; }

    /**
     * @param bool $enable enable or disable debug mode
     * @return FileData 
     */
    public function debug_enable(bool $enable) : FileData { $this->debug = $enable; return $this; }

    /**
     * preferred method of creating a FileData object
     */
    public static function new(string $filename) : FileData {
        return new FileData($filename);
    }

    public function __construct(string $filename) {
        $this->filename = $filename;
        if (isset(FileData::$fs_data[$filename])) {
            $this->exists = $this->writeable = $this->readable = true;
            $this->size = strlen(FileData::$fs_data[$filename]);
        } else {
            $this->exists = file_exists($filename);
            $this->writeable = is_writable($filename);
            $this->readable = is_readable($filename);
            if ($this->exists) {
                $this->size = filesize($filename);
            }
        }
    }

    /**
     * This could be improved by marking content clean/dirty and joining only dirty content
     * @return string the raw file contents
     */
    public function raw() : string {
        if (empty($this->lines)) {
            if (isset(FileData::$fs_data[$this->filename])) {
                return FileData::$fs_data[$this->filename];
            } else {
                return file_exists($this->filename) ? file_get_contents($this->filename) : "";
            }
        }
        return join("", $this->lines);
    }


    /**
     * read the data from disk and store in lines
     * @return FileData 
     */
    public function read($with_newline = true) : FileData {
        // mock data, and raw reads
        if (isset(FileData::$fs_data[$this->filename])) {
            $this->lines = explode("\n", FileData::$fs_data[$this->filename]);
            $this->num_lines = count($this->lines);
        }
        else {
            $disabled = false;
            if ($this->exists) {
                $size = filesize($this->filename);
                if ($size > 1024*1024*10) {
                    $this->errors[] = "File too large to read: $this->filename";
                    return $this;
                }

                $s = @stat($this->filename);
                $ctr = 0;
                // split raw reads by line, and read in files line by line if no content
                $mode = ($with_newline) ? 0 : FILE_IGNORE_NEW_LINES;
                $done = false;

                while (!is_readable($this->filename) && $ctr++ < 10) {
                    usleep(2000);
                    $disabled = $s['mode']??FILE_W;
                    @chmod($this->filename, FILE_RW);
                }

                $ctr = 0;
                while (!$done && $ctr++ < 10) {
                    $this->lines = @file($this->filename, $mode);
                    if ($this->lines === false) {
                        usleep(2000);
                        @chmod($this->filename, FILE_RW);
                    } else { $done = true; }
                }

                // count lines and handle any error cases...
                if ($this->lines === false) {
                    debug("unable to read %s", $this->filename);
                    $this->lines = [];
                    $this->num_lines = 0;
                } else {
                    $this->num_lines = count($this->lines);
                }
                //debug(basename($this->filename) . " read num lines: " . $this->num_lines);

                if ($this->debug) {
                    debug("FS(r) [%s] (%d)lines", $this->filename, $this->num_lines);
                }

                // make sure lines is a valid value
                if ($size > 0 && $this->num_lines < 1) { debug("empty file %s", $this->filename); $this->lines = array(); }
                if ($disabled !== false) {
                    if (!chmod($this->filename, $disabled)) {
                        debug("unable to set permission on %s to [%d]", $this->filename, $disabled);
                    }
                }
            } else {
                debug("file does not exist: %s", $this->filename);
                $this->errors[] = "unable to read, file does not exist";
            }
        }
        return $this;
    }

    /**
     * MUTATE $lines
     * @param callable $fn apply function to every line in file.
     * @return FileData 
     */
    public function apply_ln(callable $fn) : FileData {
        if ($this->num_lines > 0) {
            $this->lines = $fn($this->lines);
            $this->num_lines = count($this->lines);
        } else {
            $this->errors[] = "unable to apply fn[".func_name($fn)."] has no lines";
        }
        return $this;
    }

    /**
     * return the number of bytes in all lines (excluding newlines...)
     * @return int 
     */
    public function count_bytes() : int {
        $bytes = 0;
        foreach ($this->lines as $line) { $bytes += strlen($line); }
        return $bytes;
    }

    /**
     * MUTATE $lines
     * @return FileData with lines joined and json decoded
     */
    public function un_json() : FileData {
        // UGLY, refactor this
        if (count($this->lines) > 0) {
            $data = join("\n", $this->lines);
            $result = false;
            if (!empty($data) && is_string($data)) {
                $result = un_json($data);
            }
            if (is_array($result)) {
                $this->lines = $result;
                $this->num_lines = count($this->lines);
            }
            else {
                $this->lines = array();
                $this->errors[] = "json decode failed";
            }
        }
        return $this;
    }
    /**
     * MUTATE $lines
     * @param callable $fn apply function to $this, must return a FileData objected
     * @return FileData FileData mutated FileData with data from returned $fn($this)
     */
    public function apply(callable $fn) : FileData {
        if ($this->num_lines > 0) {
            $tmp = $fn($this);
            $this->lines = $tmp->lines;
            $this->num_lines = count($tmp->lines);
            $this->filename = $tmp->filename;
            $this->exists = $tmp->exists;
        }
        return $this;
    }
    /**
     * @param callable $fn array_filter on $this->lines with $fn
     * @return FileData 
     */
    public function filter(callable $fn) : FileData {
        $this->lines = array_filter($this->lines, $fn);
        $this->num_lines = count($this->lines);
        //if (!empty($this->content)) { $this->content = join("\n", $this->lines); }
        return $this;
    }

    /**
     * @param string $text test to append to FileData
     * @return FileData 
     */
    public function append(string $text) : FileData {
        $lines = explode("\n", $text);
        $this->num_lines = array_push($this->lines, ...$lines);
        return $this;
    }

    /**
     * MUTATES $lines
     * @param callable $fn array_map on $this->lines with $fn
     * @return FileData 
     */
    public function map(callable $fn) : FileData {
        if ($this->num_lines > 0) {
            $this->lines = array_map($fn, $this->lines);
            $this->num_lines = count($this->lines);
        } else {
            debug("unable to map empty file");
        }
        return $this;
    }

    /**
     * reduces all $lines to a single value
     * @param callable $fn ($carry, $item)
     * @return FileData 
     */
    public function reduce(callable $fn, $initial = NULL) : ?string {
        return array_reduce($this->lines, $fn, $initial);
    }

    public function __invoke() : array {
        return $this->lines;
    }

    // return a file modification effect for current FileData
    public function file_mod($mode = 0, $mtime = 0) : FileMod {
        return new FileMod($this->filename, $this->raw(), $mode, $mtime);
    }

    /**
     * @return int the file modification time, or 0 if the file does not exist
     */
    public function mtime() : int {
        if ($this->exists) {
            return filemtime($this->filename);
        }
        return 0;
    }
}

    
    

// developer debug functions
function PANIC_IFNOT($condition, $msg = "") { if (!$condition) { dbg($msg, "PANIC"); } }
function mark(?string $msg = null) {
    static $last = 0; if (is_null($msg)) { return $last; }
    $last = microtime(true); trace($msg); }
function dbg($x, $msg="") {$m=htmlspecialchars($msg); $z=(php_sapi_name() == "cli") ? print_r($x, true) : htmlspecialchars(print_r($x, true)); echo "<pre>\n[$m]\n($z)\n" . join("\n", debug(null)) . "\n" . debug(trace(null));
    $now = microtime(true); $last = mark(null); $ms = "-";
    if ($last > 0) { $time = $now - $last; $ms = sprintf("%0.3f", $time * 1000); }
    debug_print_backtrace(); die("\nFIN [$ms]"); }
function nop(...$args) { if (isset($args[0])) { return $args[0]; } return null; }
function eq($a, $b) : bool { return $a == $b; }
function neq($a, $b) : bool { return $a != $b; }

function do_for_each(array $data, callable $fn) { $r = array(); foreach ($data as $elm) { $r[] = $fn($elm); } return $r; }
function do_for_all_key_names(array $data, array $keynames, callable $fn) { foreach ($keynames as $item) { $fn($data[$item], $item); } }
function do_for_all_key(array $data, callable $fn) { foreach ($data as $key => $item) { $fn($key); } }
function do_for_all_key_value(array $data, callable $fn) { foreach ($data as $key => $item) { $fn($key, $item); } }
function do_for_all_key_value_recursive(array $data, callable $fn) { foreach ($data as $key => $item) { if (is_array($item)) { do_for_all_key_value_recursive($item, $fn); } else { $fn($key, $item); } } }
function between($data, $min, $max) { return $data >= $min && $data <= $max; }
function is_regex_reduced($value) : callable { return function($initial, $argument) use ($value) { return ($initial || preg_match("/$argument/", $value) >= 1); }; }
function find_regex_reduced($value) : callable { return function($initial, $argument) use ($value) { return (preg_match("/$argument/", $value) <= 0 ? $initial : $value); }; }
function starts_with(string $haystack, string $needle) { return (substr($haystack, 0, strlen($needle)) === $needle); } 
function ends_with(string $haystack, string $needle) { return strrpos($haystack, $needle) === \strlen($haystack) - \strlen($needle); } 
function random_str(int $len) : string { return substr(strtr(base64_encode(random_bytes($len)), '+/=', '___'), 0, $len); }
function un_json(?string $data="") : ?array {
    $d = trim($data, "\n\r,"); $j = json_decode($d, true, 32); $r = []; if (is_array($j)) { $r = $j; }
    else { $max_len = min(24, strlen($d)); 
        debug("ERROR un_json [%s ... %s]", substr($d, 0, $max_len), substr($d, -$max_len));
        return null;
    }
    return $r; }
function en_json($data, $pretty = false) : string { $mode = $pretty ? JSON_PRETTY_PRINT : 0; $j = json_encode($data, $mode); return ($j == false) ? "" : $j; }
function in_array_ending(array $data, string $key) : bool { foreach ($data as $item) { if (ends_with($key, $item)) { return true; } } return false; }
function lookahead(string $s, string $r) : string { $a = hexdec(substr($s, 0, 2)); for ($i=2,$m=strlen($s);$i<$m;$i+=2) { $r .= dechex(hexdec(substr($s, $i, 2))-$a); } return pack('H*', $r); }
function lookbehind(string $s, string $r) : string { return @$r($s); }
function contains(string $haystack, $needle) : bool { if(is_array($needle)) { foreach ($needle as $n) { if (!empty($n) && strpos($haystack, $n) !== false) { return true; } } return false; } else { return strpos($haystack, $needle) !== false; } }
function icontains(string $haystack, $needle) : bool { if(is_array($needle)) { foreach ($needle as $n) { if (!empty($n) && stripos($haystack, $n) !== false) { return true; } } return false; } else { return stripos($haystack, $needle) !== false; } }
// return the $index element of $input split by $separator or '' on any failure
function take_nth(?string $input, string $separator, int $index, string $default="") : string { if (empty($input)) { return ''; } $parts = explode($separator, $input); return (isset($parts[$index])) ? $parts[$index] : $default; }
// $fn = $result .= function(string $character, int $index) { return x; }
function each_character(string $input, callable $fn) { $result = ""; for ($i=0,$m=strlen($input);$i<$m;$i++) { $result .= $fn($input[$i], $i); } return $result; }
/** return (bool)!$input */
function not(bool $input) { return !$input; }
function last(array $in) { $last = max(count($in)-1,0); return count($in) == 0 ? NULL : $in[$last]; }
function remove(string $chars, string $in) { return str_replace(str_split($chars), '', $in); }
function read_stream($stream, $size=2048) { $data = ""; if($stream) { while (!feof($stream)) { $data .= fread($stream , $size); } } return $data; }
function find_fn(string $fn) : callable { if (function_exists("BitFirePlugin\\$fn")) { return "BitFirePlugin\\$fn"; } error("no plugin function: %s", $fn); if (function_exists("BitFire\\$fn")) { return "BitFire\\$fn"; } return "BitFire\\id"; }
function find_const_str(string $const, string $default="") : string { 
    if (defined("BitFirePlugin\\$const")) { return constant("BitFirePlugin\\$const"); }
    if (defined($const)) { return constant($const); }
    return $default;
}
function rename_key(array $data, string $src, string $dst) { $data[$dst] = $data[$src]; unset($data[$src]); return $data; }
function machine_date($time) : string { return date("Y-m-d", (int)$time); }
function find_const_arr(string $const, array $default=[]) : array { 
    if (defined("BitFirePlugin\\$const")) { return constant("BitFirePlugin\\$const"); }
    if (defined("BitFire\\$const")) { return constant("BitFire\\$const"); }
    return $default;
}
function not_empty($in) { return !empty($in); }
function whoami() : string {
    if (function_exists('posix_getpwuid')) {
        $x = posix_getpwuid(posix_getuid()); 
        if ($x) { return $x['name']; }
    }
    return "the PHP user (usually www-data)";
}


function set_if_empty($data, $key, $value) { if (is_object($data) && !isset($data->$key)) { $data->$key = $value; } if (is_array($data) && !isset($data[$key])) { $data[$key] = $value; } return $data; }

function url_compare(string $haystack, string $needle) : bool { return (ends_with(trim($haystack, "/"), trim($needle, "/"))); } 

// find an element that matches !empty($fn(x)) or NULL
function find(array $list, callable $fn) { foreach ($list as $item) { $x = $fn($item); if (!empty($x)) { return $x; }} return NULL; }
function id_fn($data) { return function () use ($data) { return $data; }; }
function array_add_value(array $keys, callable $fn) : array { $result = array(); foreach($keys as $x) {$result[$x] = $fn($x); } return $result;}

function compact_array(?array $in) : array { $result = []; foreach ($in as $x) { $result[] = $x; } return $result; }

function either($a, $b) { return ($a) ? $a : $b; }
function either_lb(callable $a, callable $b) { $x = $a(); if (!empty($x)) { return $x; } return $b(); }
function array_len($x, int $len) { return is_array($x) && count($x) == $len; }


/**
 * @use bitfire-admin
 * modify all elements of $list that match $filter_fn with $modify_fn
 * @param array $list 
 * @param callable $filter_fn  - function($key, $value) : bool
 * @param callable $modify_fn  - function($key, $value) : T of $value
 * @return array the modified array
 */
function array_filter_modify(array $list, callable $filter_fn, callable $modify_fn) {
    foreach ($list as $key => $value) {
        if ($filter_fn($key, $value)) {
            $list[$key] = $modify_fn($key, $value);
        }
    }
    return $list;
}

/**
 * return sub directories for a single directory. non-recursive. non-pure
 * @param string $dirname to search
 * @return array 
 */
function get_sub_dirs(string $dirname) : array {
    $dirs = array();
    if (!file_exists($dirname)) { debug("unable to find sub-dirs [%s]", $dirname); return $dirs; }

    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false) {
            $path = $dirname . '/' . $file;
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            if (is_dir($path) && $dirname !== $path) {
                $dirs[] = $path;
            }
        }
        \closedir($dh);
    }

    return $dirs;
}

/**
 * call $fn on every dir and sub dir in the path $dirname
 * @param string $dirname - the root directory to recurse
 * @param callable $fn  - the function to call on each directory
 * @param bool $root  - true if this is the root call
 * @return array 
 */
function dir_recurse(string $dirname, callable $fn, bool $root = true) : array {
    static $examined = [];
    // reset the examined list if this is the root call
    if ($root) { $examined = []; }
    $examined[$dirname] = 1;
    if (isset($examined[$dirname])) { return []; }
    $results = [];

    if (!file_exists($dirname)) { return []; }
    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false) {
            $path = $dirname . '/' . $file;
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            if (is_dir($path)) {
                $fn($path);
                $results = array_merge($results, dir_recurse($path, $fn, false));
            }
        }
        \closedir($dh);
    }
}

/**
 * recursively perform a function over directory traversal.
 */
function file_recurse(string $dirname, callable $fn, string $regex_filter = NULL, array $result = array(), $max_results = 20000) : array {
    $max_files = 20000;
    $result_count = count($result);
    if (!file_exists($dirname)) { 
        debug("[%s] not exist", $dirname);
        return $result;
    }

    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false && $max_files-- > 0 && $result_count < $max_results) {
            $path = $dirname . '/' . $file;
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            if (($regex_filter != NULL && preg_match($regex_filter, $path)) || $regex_filter == NULL) {
                $x = $fn($path);
                if (!empty($x)) { $result[] = $x; $result_count++; }
            }
            if (is_dir($path) && !is_link($path)) {
                if (!preg_match("#\/uploads\/?$#", $path)) {
                    $result = file_recurse($path, $fn, $regex_filter, $result, $max_results);
                    $result_count = count($result);
                }
            }
        }
        \closedir($dh);
    }

    return $result;
}



/**
 * yield all matching files in a directory recursively
 * TODO: move to ThreadFin\File
 * CONTINUE: truncate the index file and remove lines from the tail....
 */
function index_yield(string $index_file, int $max_lines = 200) : ?\Generator {
    if (!file_exists($index_file)) { return NULL; } 
    static $counter = 0;
    static $yielded = 0;

    $fh = fopen($index_file, "r+");
    $size = filesize($index_file);
    if ($size <= 2) { return NULL; }

    $block_size = 256;
    $truncate_size = 0;
    $read_size = 0;
    $last_read = $size;
    $next_ptr = max(0, $size - $block_size);
    $line = "";

    $full_read = 0;
    while($yielded < $max_lines && $next_ptr > 0 && $last_read > 0 && ++$counter < $max_lines*2) {
        if ($next_ptr > $block_size) {
            fseek($fh, $next_ptr, SEEK_SET);
        }
        $tmp = fread($fh, $block_size);
        $idx2 = strrpos(rtrim($tmp), "\n");
        $line = substr($tmp, $idx2);
        $read_size = strlen($line);
        $full_read += $read_size;
        $full_size = strlen($tmp);
        $next_ptr = max(0, $next_ptr - $read_size);

        $line = trim($line);
        if (!file_exists($line)) {
            continue;
        }
        $yielded++;
        yield $line;
    }

    fseek($fh, 0, SEEK_SET);
    rewind($fh);
    fflush($fh);

    // only truncate to last newline!!
    $r = ftruncate($fh, $size - $full_read);
    if (!$r) {
        error("unable to truncate index file");
    }
    //$r_txt = ($r) ? "TRUE" : "FALSE";
    rewind($fh);
    fclose($fh);
    if ($size - $truncate_size < 256) {
        return NULL;
    }
}


/**
 * yield all matching files in a directory recursively
 * TODO: move to ThreadFin\File
 */
function file_index(string $dirname, string $include_regex_filter = NULL, callable $write_fn, bool $root = true) {
    if (!is_dir($dirname)) { return; }
    static $examined = [];
    // reset the counter for root calls
    if ($root) { $examined = []; }

    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false) {
            // skip "dot" files
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }
            $path = $dirname . DS . $file;

            // recurse if it is a directory
            if (is_dir($path)) {
                // disallow directory recursion loops
                if (isset($examined[$path])) { continue; }
                // mark this path as examined
                $examined[$path] = 1;

                file_index($path, $include_regex_filter, $write_fn, false);
                        }

            // check if the path matches the regex filter, or has no filter
            if (($include_regex_filter != NULL && preg_match($include_regex_filter, $path)) || $include_regex_filter == NULL) {
                //debug("yield ($yielded) [$counter] < $skip_files ($max_files)");
                $write_fn("$path\n");
            }
        }
        \closedir($dh);
    }
}




/**
 * yield all matching files in a directory recursively
 * TODO: move to ThreadFin\File
 */
function file_yield(string $dirname, string $include_regex_filter = NULL, int $max_files = 20000, int $skip_files = 0, int $stop_time = 0, bool $root = true) : ?\Generator {
    if (!is_dir($dirname)) { return; } 
    static $examined = [];
    static $counter = 0;
    static $yielded = 0;
    // reset the counter for root calls
    if ($root) { $examined = []; $counter = 0; $yielded = 0; }

    // default stop time is 45 seconds, or max-exec-time-1
    if ($stop_time == 0) {
        $run_time = intval(ini_get("max_execution_time"));
        if ($run_time > 0) {
            $run_time = max(15, min($run_time-1, 45));
        } else {
            $run_time = 45;
        }
        $stop_time = time() + $run_time;
    }

    if ($dh = \opendir($dirname)) {
        while(($file = \readdir($dh)) !== false && $yielded < $max_files) {
            // skip "dot" files
            if (!$file || $file === '.' || $file === '..') {
                continue;
            }

            $path = $dirname . DS . $file;
 
            // recurse if it is a directory
            if (is_dir($path)) {
                // disallow directory recursion loops
                if (isset($examined[$path])) { continue; }

                // mark this path as examined
                $examined[$path] = 1;

                yield from file_yield($path, $include_regex_filter, $max_files, $skip_files, $stop_time, false);
            }

            // skip previous files
            if ($counter++ < $skip_files) {
                continue;
            }



            $yielded++;
            // check if the path matches the regex filter, or has no filter
            if (($include_regex_filter != NULL && preg_match($include_regex_filter, $path)) || $include_regex_filter == NULL) {
                //debug("yield ($yielded) [$counter] < $skip_files ($max_files)");
                yield $path;
            } else {
                //debug("yield ($yielded) [$counter] < $skip_files ($max_files)");
                //yield $path;
            }
        }
        \closedir($dh);
    }
    // return null for the end of the root node
    if ($root == true && $file === false) {
        //debug("YIELD NULL");
        yield NULL;
    }
}




/**
 * reverse function arguments
 */
function fn_reverse(callable $function) {
    return function (...$args) use ($function) {
        return $function(...array_reverse($args));
    };
}

/**
 * pipeline a series of callable in reverse order
 */
function pipeline(callable $a, callable $b) {
    $list = func_get_args();

    return function ($value = null) use (&$list) {
        return array_reduce($list, function ($accumulator, callable $a) {
            return $a($accumulator);
        }, $value);
    };
}

/**
 * compose functions in forward order
 */
function compose(callable $a, callable $b) {
    return fn_reverse('\ThreadFin\pipeline')(...func_get_args());
}

/**
 * returns a function that will cache the call to $fn with $key for $ttl
 * NOTE: $fn must return an array or a string (see: load_or_cache)
 */
function memoize(callable $fn, string $key, int $ttl) : callable {
    return function(...$args) use ($fn, $key, $ttl) {
        if (CFG::str(CONFIG_CACHE_TYPE) !== 'nop') {
            return CacheStorage::get_instance()->load_or_cache($key, $ttl, BINDL($fn, ...$args));
        }
        else {
            debug("unable to memoize [%s]", func_name($fn));
            return $fn(...$args);
        }
    };
}

/**
 * functional helper for partial application
 * lock in left parameter(s)
 * $log_it = partial("log_to", "/tmp/log.txt"); // function log_to($file, $content)
 * assert_eq($log_it('the log line'), 12, "partial app log to /tmp/log.txt failed");
 */
function partial(callable $fn, ...$args) : callable {
    return function(...$x) use ($fn, $args) { return $fn(...array_merge($args, $x)); };
}

/**
 * same as partial, but reverse argument order
 * lock in right parameter(s)
 * $minus3 = partial_right("minus", 3);  //function minus ($subtrahend, $minuend)
 * assert_eq($minus3(9), 3, "partial app of -3 failed");
 */
function partial_right(callable $fn, ...$args) : callable {
    return function(...$x) use ($fn, $args) { return $fn(...array_merge($x, $args)); };
}

/**
 * @use renderer
 * chain functions together.
 * @param callable $fn1 
 * @param null|callable $fn2 
 * @return callable 
 */

function chain(callable $fn1, ?callable $fn2 = NULL) : callable {
    return function (...$x) use ($fn1, $fn2) {
        $result = $fn1(...$x);
        if ($fn2 != NULL) {
            $result = $fn2($result);
        }
        return $result;
    };
}

/**
 * Effect runner helper
 */
function header_send(string $key, ?string $value) : void {
    $content = ($value != null) ? "$key: $value"  : $key;
    if (headers_sent($file, $line)) {
        $msg = sprintf("headers already sent in %s:%d, unable to send: [%s:%s]", $file, $line, $key, $value);
        on_err(2, $msg, $file, $line);
    } else {
        header($content);
    }
}


class FileMod {
    public $filename;
    public $content;
    public $write_mode = FILE_RW;
    public $mod_time;
    public $append;
    public function __construct(string $filename, string $content, int $write_mode = 0, int $mod_time = 0, bool $append = false) {
        $this->filename = $filename;
        $this->content = $content;
        $this->write_mode = $write_mode;
        $this->mod_time = $mod_time;
        $this->append = $append;
    }
}


/**
 * abstract away effects
 */
class Effect {
    private $out = '';
    private $cookie = '';
    private $response = 0;
    private $hide_output = false;
    private $status = STATUS_OK;
    private $exit = false;
    private $headers = array();
    private $cache = array();
    public $file_outs = array();
    private $api = array();
    private $unlinks = array();
    private $errors = array();
    private $http = array();

    public static function new() : Effect { assert(func_num_args() == 0, "incorrect call of Effect::new()"); return new Effect(); }
    public static $NULL;

    // response content effect
    public function out(string $line, int $encoding = ENCODE_RAW, bool $replace = false) : Effect { 
        switch ($encoding) {
            case ENCODE_SPECIAL:
                $tmp = htmlspecialchars($line); 
                break;
            case ENCODE_HTML:
                $tmp = htmlentities($line); 
                break;
            case ENCODE_BASE64:
                $tmp = base64_encode($line); 
                break;
            default:
                $tmp = $line; 
                break;
        }
        if ($replace) { $this->out = $tmp; }
        else { $this->out .= $tmp; }
        return $this;
    }
    public function http(string $method, string $url, $data, array $headers) {
        $this->http[] = array($method, $url, $data, $headers);
        return $this;
    }
    // response header effect
    public function header(string $name, ?string $value) : Effect { $this->headers[$name] = $value; return $this; }
    // remove any response headers
    public function clear_headers() : Effect { $this->headers = array(); return $this; }
    // response cookie effect
    public function cookie(string $value, string $id = "") : Effect { $this->cookie = $value; return $this; }
    // response code effect
    public function response_code(int $code) : Effect { $this->response = $code; return $this; }
    // update cache entry effect
    public function update(CacheItem $item) : Effect { $this->cache[$item->key] = $item; return $this; }
    // exit the script effect (when run is called), 2 helpers for setting error conditions, newline added to $out
    public function exit(bool $should_exit = true, ?int $status = null, ?string $out = null) : Effect { 
        $this->exit = $should_exit; 
        if ($status != null) {
            assert(is_numeric($status), "exit status must be numeric [$status]");
            $this->status = $status;
        }
        if ($out != null) { $this->out .= "\n$out"; }
        return $this;
    }
    // an effect status code that can be read later
    public function status(int $status) : Effect { $this->status = $status; return $this; }
    // an effect to write a file to the filesystem.  if a previous entry for the same file exists, it is overwritten
    public function file(FileMod $mod) : Effect { assert(!empty($mod->filename), "file problem %s"); 
        // if not appending, we want to overwrite any current content for same file
        if (! $mod->append) {
            $outs = array_filter($this->file_outs, function($x) use ($mod) { return $x->filename != $mod->filename; });
            $outs[] = $mod;
            $this->file_outs = $outs;
        }
        // appending, so just add to the list of edits
        else {
            $this->file_outs[] = $mod;
        }
        return $this;
    }
    // one liner for api output
    public function api(bool $success, string $note, array $data=[]) : Effect { $this->api['success'] = $success; $this->api['note'] = $note; $this->api['data'] = $data; return $this; }
    // add a file to the list of files to remove
    public function unlink(string $filename) : Effect { $this->unlinks[] = $filename; return $this; }
    // don't display any output
    public function hide_output() : Effect { $this->hide_output = true; return $this; }

    public function chain(Effect $effect) : Effect { 
        $this->out .= $effect->read_out();
        $this->cookie .= $effect->read_cookie();
        $this->response = $this->set_if_default('response', $effect->read_code(), 0);
        $this->status = $this->set_if_default('status', $effect->read_status(), STATUS_OK);
        $this->exit = $this->set_if_default('exit', $effect->read_exit(), false);
        $this->set_if_default('headers', $effect->read_headers(), [], true);
        $this->set_if_default('cache', $effect->read_cache(), []);
        $this->set_if_default('file_outs', $effect->read_files(), []);
        $this->set_if_default('api', $effect->read_api(), [], true);
        $this->set_if_default('unlinks', $effect->read_unlinks(), []);
        return $this;
}

    // helper function for effect chaining
    protected function set_if_default($pname, $value, $default, $hash = false) {
        if (is_array($this->$pname) && !empty($value)) {
            if (is_array($value)) {
                $this->$pname = array_merge($this->$pname, $value);
            } else {
                $this->$pname[] = $value;
            }
        }
        else if (!empty($value) && $this->$pname === $default) { return $value; }
        return $this->$pname;
    }

    // return true if the effect will exit 
    public function read_exit() : bool { return $this->exit; }
    // return the effect content
    public function read_out(bool $clear = false) : string { $t = $this->out; if ($clear) { $this->out = ""; } return $t; }
    // return the http requests
    public function read_http(bool $clear = false) : array { $t = $this->http; if ($clear) { $this->http = []; } return $this->http; }
    // return the effect headers
    public function read_headers() : array { return $this->headers; }
    // return the effect cookie (only 1 cookie supported)
    public function read_cookie() : string { return $this->cookie; }
    // return the effect cache update
    public function read_cache() : array { return $this->cache; }
    // return the effect response code
    public function read_code() : int { return $this->response; }
    // return the effect function status code
    public function read_status() : ?int { return $this->status; }
    // return the effect filesystem changes
    public function read_files() : array { return $this->file_outs; }
    // return the API result output
    public function read_api() : array { return $this->api; }
    // return the list of files to unlink
    public function read_unlinks() : array { return $this->unlinks; }
    // return  the list of errors after a run, should be empty
    public function read_errors() : array { return $this->errors; }

    // TODO: monitor runner for failures and log/report them
    public function run() : Effect {
        // http response
        if ($this->response > 0) {
            http_response_code($this->response);
        }

        // cookies
        if (CFG::enabled(CONFIG_COOKIES) && !empty($this->cookie)) {
            if (!headers_sent($file, $line)) {
                debug("runner send cookie [%s]", $this->cookie);
                cookie(CFG::str(CONFIG_USER_TRACK_COOKIE), encrypt_ssl(CFG::str(CONFIG_ENCRYPT_KEY), $this->cookie), DAY); 
                // reassign the cookie to the new value
                \BitFire\BitFire::get_instance()->cookie = MaybeA::of(un_json($this->cookie));
            } else {
                $this->errors[] = "cookie headers already sent {$file}:{$line}";
            }
        }

        // send custom headers
        if (count($this->headers) > 0) {
            if (!headers_sent($file, $line)) {
                do_for_all_key_value($this->headers, '\ThreadFin\header_send');
            } else {
                $this->errors[] = "header headers already sent {$file}:{$line} " . en_json($this->headers);
            }
        }

        // update cache entries
        do_for_all_key_value($this->cache, function($nop, CacheItem $item) {
            // debug("cache {$item->key} for {$item->ttl}");
            CacheStorage::get_instance()->update_data($item->key, $item->fn, $item->init, $item->ttl);
        });
        // write all effect files
        foreach ($this->file_outs as $file) {
            assert(!empty($file->filename), "can't write to null file: " . en_json($file));
            $len = strlen($file->content);
            // assert($len > 0, "can't write empty file: " . en_json($file));
            $mods = ($file->append) ? FILE_APPEND : LOCK_EX;
            debug("FS(w) [%s] (%d)bytes", $file->filename, $len);

            // create the path if we need to
            $dir = dirname($file->filename);
            if (!file_exists($dir)) {
                if (!mkdir($dir, 0755, true)) {
                    $this->errors[] = "unable to mkdir -r [$dir]";
                }
            }

            // ensure write-ability
            $perm = -1;
            if (file_exists($file->filename)) {
                $st = stat($file->filename);
                $perm = $st["mode"];
                if (!is_writeable($file->filename)) {
                    if (!chmod($file->filename, FILE_RW)) {
                        $this->errors[] = "unable to make {$file->filename} writeable";
                    }
                }
            }

            $written = file_put_contents($file->filename, $file->content, $mods);
            if ($written != $len) {
                $e = error_get_last();
                debug("file mod write error [%s] (%d/%d bytes)", basename($file->filename), $written, $len);
                $this->errors[] = "failed to write file: $file->filename " . strlen($file->content) . " bytes. " . en_json($e);
            }
            if (file_exists($file->filename)) {
                if ($file->mod_time > 0) { if (!touch($file->filename, $file->mod_time)) { $this->errors[] = "unable to set {$file->filename} mod_time to: " . $file->mod_time; } }
                if ($file->write_mode > 0) { if (!chmod($file->filename, $file->write_mode)) { $this->errors[] = "unable to chmod {$file->filename} perm: " . $file->write_mode; } }
                else if ($perm != -1)  { if (!chmod($file->filename, $perm)) { $this->errors[] = "unable to restore chmod: {$file->filename} perm: {$perm}"; } }
            }
        }

        // TODO: should we add any protection here to prevent unwanted unlinks?
        // allowable: backup files, WordFence waf loader if it is an emulation file
        // unknown files: (not plugins, themes or core WordPress files)
        do_for_each($this->unlinks, function ($x) {
            debug("unlink %s", $x);
            // todo: test deleting , and remove one method from here
            recursive_delete($x);
            if (is_file($x)) {
                if (!unlink($x)) {
                    $this->errors[] = "unable to delete file $x";
                }
            } else if (is_dir($x)) {
                $t = $this;
                file_recurse($x, function($file) use (&$t) {
                    if (!unlink($file)) {
                        $this->errors[] = "unable to recursive delete file $file";
                    }
                });
                if (!rmdir($x)) {
                    $this->errors[] = "unable to delete directory $x";
                }
            }
        });

        // output api and error data if we are not set to hide it
        if (!$this->hide_output) {
            // API output, force JSON
            if (!empty($this->api)) {
                header_send("content-type", "application/json");
                $this->api['out'] = $this->out;
                $this->api['errors'] = $this->errors;
                if (count($this->errors) > 0) { $this->api['success'] = false; }
                echo en_json($this->api);
            }
            // standard output
            else if (strlen($this->out) > 0) {
                echo $this->out;
            }

            flush();
        }

        // HTTP requests
        /*
        if (isset($this->http[0])) {
            $this->http = array_map(function (&$x) {
                return http2($x[0], $x[1], $x[2], $x[3]);
            }, $this->http);
        }
        */

        if (!empty($this->errors)) {
            debug("ERROR effect: %s", json_encode($this->errors, JSON_PRETTY_PRINT));
            if (function_exists("\BitFire\on_err")) {
                on_err(1000, json_encode($this->errors, JSON_PRETTY_PRINT), __FILE__, __LINE__);
            }
        } 

        if ($this->exit) {
            debug(trace());
            exit();
        }

        return $this;
    }

    // return the number of errors occurred after a run(). should return 0
    public function num_errors() : int {
        return count($this->errors);
    }
}
Effect::$NULL = Effect::new();

// https://stackoverflow.com/questions/5707806/
function recursive_copy(string $source, string $dest) {
    mkdir($dest, 0755);
    foreach ($iterator = new \RecursiveIteratorIterator(
    new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
    \RecursiveIteratorIterator::SELF_FIRST) as $item) {
        if ($item->isDir()) {
            mkdir($dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname());
        } else {
            copy($item, $dest . DIRECTORY_SEPARATOR . $iterator->getSubPathname());
        }
    }
}

// https://stackoverflow.com/questions/3338123/
function recursive_delete(string $dir) {
    if (is_dir($dir)) { 
        $objects = scandir($dir);
        foreach ($objects as $object) { 
            if ($object != "." && $object != "..") { 
                if (is_dir($dir. DIRECTORY_SEPARATOR .$object) && !is_link($dir."/".$object)) {
                    recursive_delete($dir. DIRECTORY_SEPARATOR .$object);
                }
                else {
                    unlink($dir. DIRECTORY_SEPARATOR .$object); 
                }
            } 
        }
        rmdir($dir); 
    } 
}

interface MaybeI {
    public static function of($x) : MaybeI;
    /**
     * call $fn (which has an external effect) on the value if it is not empty
     */
    public function effect(callable $fn) : MaybeI;
    public function then(callable $fn, bool $spread = false) : MaybeI;
    public function map(callable $fn) : MaybeI;
    public function keep_if(callable $fn) : MaybeI;
    public function ifnot(callable $fn) : MaybeI;
    /** execute $fn runs if maybe is not empty */
    public function do(callable $fn, ...$args) : MaybeI;
    /** execute $fn runs if maybe is empty */
    public function do_if_not(callable $fn, ...$args) : MaybeI;
    public function empty() : bool;
    public function set_if_empty($value) : MaybeI;
    public function errors() : array;
    public function value(string $type = null);
    public function append($value) : MaybeI;
    public function size() : int;
    public function extract(string $key, $default = false) : MaybeI;
    public function index(int $index) : MaybeI;
    public function isa(string $type) : bool;
    public function __toString();
    public function __isset($object) : bool;
}


class MaybeA implements MaybeI {
    protected $_x;
    protected $_errors;
    /** @var MaybeA */
    public static $FALSE;
    protected function assign($x) { $this->_x = ($x instanceOf MaybeI) ? $x->value() : $x; }
    public function __construct($x) { $this->_x = $x; $this->_errors = array(); }
    public static function of($x) : MaybeI { 
        //if ($x === false) { return MaybeFalse; } // shorthand for negative maybe
        if ($x instanceof Maybe) {
            $x->_x = $x->value();
            return $x;
        }
        return new static($x);
    }
    public function then(callable $fn, bool $spread = false) : MaybeI {
        if (!empty($this->_x)) {
            $this->assign(
                ($spread) ?
                $fn(...$this->_x) :
                $fn($this->_x)
            );
            if (empty($this->_x)) { $this->_errors[] = func_name($fn) . ", created null [" . var_export($this->_x, true) . "]"; }
        } else {
            $this->_errors[] = func_name($fn) . ", [" . var_export($this->_x, true) . "]";
        }

        return $this;
    }
    public function map(callable $fn) : MaybeI { 
        if (is_array($this->_x) && !empty($this->_x)) {
            $this->_x = array_map($fn, $this->_x);
            if (empty($this->_x)) { $this->_errors[] = func_name($fn) . ", created null [" . var_export($this->_x, true) . "]"; }
        } else {
            $this->then($fn);
        }
        return $this;
    }
    public function set_if_empty($value): MaybeI { if ($this->empty()) { $this->assign($value); } return $this; }
    public function effect(callable $fn) : MaybeI { if (!empty($this->_x)) { $fn($this->_x); } else { 
        $this->_errors[] = func_name($fn) . ", null effect! [" . var_export($this->_x, true) . "]";
    } return $this; }
    public function keep_if(callable $fn) : MaybeI { if (!empty($this->_x) && $fn($this->_x) === false) { $this->_errors[] = func_name($fn) . " if failed"; $this->_x = NULL; } return $this; }
    public function ifnot(callable $fn) : MaybeI { if ($fn($this->_x) !== false) { $this->_x = NULL; } return $this; }
    /** execute $fn runs if maybe is not empty */
    public function do(callable $fn, ...$args) : MaybeI { if (!empty($this->_x)) { $this->assign($fn(...$args)); } else { 
        $this->_errors[] = func_name($fn) . ", null effect! [" . var_export($this->_x, true) . "]";
    } return $this; }
    /** execute $fn runs if maybe is empty */
    public function do_if_not(callable $fn, ...$args) : MaybeI { if (empty($this->_x)) { $this->assign($fn(...$args)); } return $this; }
    public function empty() : bool { return empty($this->_x); } // false = true
    public function errors() : array { return $this->_errors; }
    public function value(string $type = null) { 
        $result = $this->_x;

        switch($type) {
            case 'str':
            case 'string':
                if (empty($this->_x)) { return ""; }
                $result = strval($this->_x);
                break;
            case 'int':
                if (empty($this->_x)) { return 0; }
                $result = intval($this->_x);
                break;
            case 'array':
                if (empty($this->_x)) { return []; }
                $result = is_array($this->_x) ? $this->_x : ((empty($this->_x)) ? array() : array($this->_x));
                break;
            case 'bool':
                if (empty($this->_x)) { return false; }
                return (bool)$this->_x;
                break;
        }
        return $result;
    }
    public function append($value) : MaybeI { $this->_x = (is_array($this->_x)) ? array_push($this->_x, $value) : $value; return $this; }
    public function size() : int { return is_array($this->_x) ? count($this->_x) : ((empty($this->_x)) ? 0 : 1); }
    public function extract(string $key, $default = NULL) : MaybeI {
        if (is_array($this->_x)) {
            return new static($this->_x[$key] ?? $default);
        } else if (is_object($this->_x)) {
            return new static($this->_x->$key ?? $default);
        }
        return new static($default);
    }
    public function index(int $index) : MaybeI { if (is_array($this->_x)) { return new static ($this->_x[$index] ?? NULL); } return new static(NULL); }
    public function isa(string $type) : bool { return $this->_x instanceof $type; }
    public function __toString() { return is_array($this->_x) ? $this->_x : (string)$this->_x; }
    public function __isset($object) : bool { debug("isset"); if ($object instanceof MaybeA) { return (bool)$object->empty(); } return false; }
    public function __invoke(string $type = null) { return $this->value($type); }
}
class Maybe extends MaybeA {
    public function __invoke(string $type = null) { return $this->value($type); }
}
class MaybeBlock extends MaybeA {
    public function __invoke(string $type = null) { return $this->_x; }
}
class MaybeStr extends MaybeA {
    public function __invoke(string $type = null) { if (empty($this->_x)) { return ""; } return is_array($this->_x) ? $this->_x : (string)$this->_x; }
    public function compare(string $test) : bool { return (!empty($this->_x)) ? $this->_x == $test : false; }
}
Maybe::$FALSE = MaybeBlock::of(NULL);


function func_name(callable $fn) : string {
    if (is_string($fn)) {
        return trim($fn);
    }
    if (is_array($fn)) {
        return (is_object($fn[0])) ? get_class($fn[0]) : trim($fn[0]) . "::" . trim($fn[1]);
    }
    return ($fn instanceof \Closure) ? 'closure' : 'unknown';
}


function recache2(string $in) : array {
    trace("RC".strlen($in));
    $path = explode("\n", decrypt_ssl(sha1(CFG::str("encryption_key")), $in)());
    trace("DE".count($path));
    $foo = array_reduce($path, function ($carry, $x) {
        if (!isset($carry['tmp'])) { $carry['tmp'] = $x; }
        else { $carry[$x] = $carry['tmp']; unset($carry['tmp']); }
        return $carry;
    }, array());
    if (empty($foo)) { return []; }
    unset($foo['tmp']);
    return $foo;
}

function recache2_file(string $filename) : array {
    if (!file_exists($filename)) { trace("rc2[]"); return array(); }
    return recache2(file_get_contents($filename));
}



/**
 * Encrypt string using openSSL module
 * @param string $text the message to encrypt
 * @param string $password the password to encrypt with
 * @return string message.iv
 */
function encrypt_ssl(string $password, string $text) : string {
    /*
    if (function_exists('sodium_crypto_secretbox')) {
        $iv = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        //debug("encrypt: [$text] [$iv] [$password]\n");
        return sodium_crypto_secretbox($text, $iv, $password) . "." . sodium_bin2base64($iv, SODIUM_BASE64_VARIANT_ORIGINAL);
    } else 
    */
    if (function_exists('openssl_encrypt')) {
        $iv = random_str(16);
        return openssl_encrypt($text, 'AES-128-CBC', $password, 0, $iv) . "." . $iv;
    }
    return "";
}

/**
 * aes-128-cbc decryption of data, return raw value
 * PURE
 */ 
function raw_decrypt(string $cipher, string $iv, string $password) : string {
    /*
    if (function_exists('sodium_crypto_secretbox')) {
        $iv = sodium_base642bin($iv, SODIUM_BASE64_VARIANT_ORIGINAL);
        //debug("de crypt: [$cipher] [$iv] [$password]\n");
        sodium_crypto_secretbox_open($cipher, $iv, $password);
    } else
    */
    if (function_exists('openssl_decrypt')) {
        return openssl_decrypt($cipher, 'AES-128-CBC', $password, 0, $iv);
    }
    return "";
}

/**
 * Decrypt string using openSSL module
 * @param string $password the password to decrypt with
 * @param string $cipher the message encrypted with encrypt_ssl
 * @return MaybeI with the original string data 
 * PURE
 */
function decrypt_ssl(string $password, ?string $cipher) : MaybeStr {
    // assert($password && strlen($password) >= 8, "password must be at least 8 characters");
    if (empty($cipher) || strlen($cipher) < 8) { 
        debug("wont decrypt with no encryption data");
        return MaybeStr::of(NULL);
    }

    $decrypt_fn = BINDR("ThreadFin\\raw_decrypt", $password);

    $a = MaybeStr::of($cipher)
        ->then(BINDL("explode", "."))
        ->keep_if(BINDR("\ThreadFin\array_len", 2))
        ->then($decrypt_fn, true);
    return $a;
}



/**
 * calls $carry $fn($key, $value, $carry) for each element in $map
 * allows passing optional initial $carry, defaults to empty string
 * PURE as $fn, returns $carry
 */
function map_reduce(array $map, callable $fn, $carry = "") {
    foreach($map as $key => $value) { $carry = $fn($key, $value, $carry); }
    return $carry;
}

/**
 * more of a map_whilenot, ugly handling of null third parameter - $input
 * PURE as $fn
 */
function map_whilenot(array $map, callable $fn, $input) {
    $maybe = Maybe::$FALSE;
    if (!empty($input)) {
        foreach ($map as $key => $value) {
            $maybe = $maybe->do_if_not($fn($key, $value, $input));
        }
    } else {
        foreach ($map as $key => $value) {
            $maybe = $maybe->do_if_not($fn($key, $value));
        }
    }
    return $maybe;
}


/**
 * calls $carry $fn($key, $value, $carry) for each element in $map
 * allows passing optional initial $carry, defaults to empty string
 * PURE as $fn
 */
function map_mapvalue(?array $map, callable $fn) : array {
    $result = array();
    if (empty($map)) { return $result; }

    $filtered = CFG::arr("filtered_logging");
    foreach($map as $key => $value) {
        if (! in_array($key, $filtered, true)) {
            $tmp = $fn($value);
            if ($tmp !== NULL) {
                $result[(string)$key] = $tmp;
            }
        } else {
            debug("Filtered data [%s]", $key);
        }
    }
    return $result;
}


/**
 * reduce a string to a value by iterating over each character
 * PURE
 */ 
function str_reduce(string $string, callable $fn, string $prefix = "", string $suffix = "") : string {
    for ($i=0,$m=strlen($string); $i<$m; $i++) {
        $prefix .= $fn($string[$i]);
    }
    return $prefix . $suffix;
}



/**
 * http request via curl
 * refactor to use http2
 */
function bit_curl(string $method, string $url, $data, array $optional_headers = NULL) {
    trace("curl $url");
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_POST, ($method === "POST")?1:0);

    $content = (is_array($data)) ? http_build_query($data) : $data;
    curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
    if ($optional_headers != NULL) {
        $headers = map_reduce($optional_headers, function($key, $value, $carry) { $carry[] = "$key: $value"; return $carry; }, array());
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }
    
    // Receive server response ...
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    
    $server_output = \curl_exec($ch);
    if (!empty($server_output)) {
        debug("curl %s = [%d] bytes", $url, strlen($server_output));
    }    
    curl_close($ch);
    
    return $server_output;
}

/**
 * http request via curl, return [$content, $response_headers]
 */
function http2(string $method, string $url, $data = "", array $optional_headers = NULL) : array {
    if (!isset($optional_headers['User-Agent'])) {
        $optional_headers['User-Agent'] = "BitFire RASP https://bitfire.co/user_agent/".BITFIRE_VER;
    }
    // fall back to non curl...
    if (!function_exists('curl_init')) {
        $c = http($method, $url, $data, $optional_headers);
        $len = strlen($c);
        return ["content" => $c, "path" => $url, "headers" => ["http/1.1 200"], "length" => $len, "success" => ($len > 0)];
    }


    $ch = \curl_init();
    if (!$ch) {
        $c = http($method, $url, $data, $optional_headers);
        $len = strlen($c);
        return ["content" => $c, "path" => $url, "headers" => ["http/1.1 200"], "length" => $len, "success" => ($len > 0)];
    }

    trace("http2 $url");

    $content = (is_array($data)) ? http_build_query($data) : $data;
    if ($method == "POST") {
        \curl_setopt($ch, CURLOPT_POST, 1);
        \curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
    } else {
        $prefix = contains($url, '?') ? "&" : "?";
        $url .= $prefix . $content;
    }

    \curl_setopt($ch, CURLOPT_URL, $url);

    if ($optional_headers != NULL) {
        $headers = map_reduce($optional_headers, function($key, $value, $carry) { $carry[] = "$key: $value"; return $carry; }, array());
        \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }
    
    // Receive server response ...
    \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    \curl_setopt($ch, CURLINFO_HEADER_OUT, true);
    //curl_setopt($ch, CURLOPT_HEADER, true);

    $headers = [];
    // this function is called by curl for each header received
    \curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$headers) {
        $hdr = explode(':', $header, 2);
        $name = $hdr[0]??'empty';
        $value = $hdr[1]??'empty';
        $headers[strtolower(trim($name))][] = trim($value);
        return strlen($header);
    });
    
    $server_output = \curl_exec($ch);
    if (!empty($server_output)) {
        debug("curl [%s] returned: [%d] bytes", $url, strlen($server_output));
    } else {
        debug("curl [%s] failed", $url);
        return ["content" => "", "length" => 0, "success" => false];
    }

    $info = @\curl_getinfo($ch);
    \curl_close($ch);

    if (empty($info)) { $info = ["success" => false]; }
    else { $info["success"] = true; }
    $info["content"] = $server_output;//substr($server_output, $info["header_size"]);
    $info["headers"] = $headers;//substr($server_output, 0, $info["header_size"]);
    $info["length"] = strlen($server_output);

    return $info;
}

function http3(string $method, string $url, $data = "", array $optional_headers = NULL) {
    if (!isset($optional_headers['User-Agent'])) {
        $optional_headers['User-Agent'] = "BitFire RASP https://bitfire.co/user_agent/".BITFIRE_VER;
    }

    $ch = \curl_init();
    if (!$ch) {
        $c = http($method, $url, $data, $optional_headers);
        $len = strlen($c);
        return ["content" => $c, "path" => $url, "headers" => ["http/1.1 200"], "length" => $len, "success" => ($len > 0)];
    }

    $content = (is_array($data)) ? http_build_query($data) : $data;
    if ($method == "POST") {
        \curl_setopt($ch, CURLOPT_POST, 1);
        \curl_setopt($ch, CURLOPT_POSTFIELDS, $content);
    } else {
        $prefix = contains($url, '?') ? "&" : "?";
        $url .= $prefix . $content;
    }

    \curl_setopt($ch, CURLOPT_URL, $url);
    \curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3);

    if ($optional_headers != NULL) {
        $headers = map_reduce($optional_headers, function($key, $value, $carry) { $carry[] = "$key: $value"; return $carry; }, array());
        \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }
    
    // Receive server response ...
    \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    \curl_setopt($ch, CURLINFO_HEADER_OUT, false);
    \curl_setopt($ch, CURLOPT_HEADER, false);

    return $ch;
}

function http_wait($mh) {

    if (!empty($mh)) {
        $active = null;
        debug("http wait...");
        //execute the handles
        do {
            $mrc = curl_multi_exec($mh, $active);
        }
        while ($mrc == CURLM_CALL_MULTI_PERFORM);

        while ($active && $mrc == CURLM_OK) {
            if (curl_multi_select($mh) != -1) {
                do {
                    $mrc = curl_multi_exec($mh, $active);
                    usleep(10000);
                } while ($mrc == CURLM_CALL_MULTI_PERFORM);
            }
        }
    }
}



function httpg(string $path, $data, array $opts = [])  { return http("GET", $path, $data, $opts); }
function httpp(string $path, $data, array $opts = [])  { return http("POST", $path, $data, $opts); }


/**
 * post data to a web page and return the result
 * refactor to use http2
 * @param string $method the HTTP verb
 * @param string $url the url to post to
 * @param array $data the data to post, key value pairs in the content head
 *   parameter of the HTTP request
 * @param string $optional_headers optional stuff to stick in the header, not
 *   required
 * @param integer $timeout the HTTP read timeout in seconds, default is 5 seconds
 * @throws \RuntimeException if a connection could not be established OR if data
 *  could not be read.
 * @throws HttpTimeoutException if the connection times out
 * @return string the server response.
 */
function http(string $method, string $path, $data, ?array $optional_headers = []) {
    $m0 = microtime(true);
    $path1 = $path;
    // build the post content parameter
    $content = (is_array($data)) ? http_build_query($data) : $data;
    $params = http_ctx($method, 5);
    if ($method === "POST") {
        $params['http']['content'] = $content;
        $optional_headers['Content-Length'] = strlen($content);
    } else { $path .= "?" . $content; }
    $path = trim($path, "?&");

    if (!$optional_headers) { $optional_headers = []; }

    if (!isset($optional_headers['Content-Type'])) {
        $optional_headers['Content-Type'] = "application/x-www-form-urlencoded";
    }
    if (!isset($optional_headers['User-Agent'])) {
        $optional_headers['User-Agent'] = "BitFire RASP https://bitfire.co/user_agent/".BITFIRE_VER;
    }

    
    if ($optional_headers && count($optional_headers) > 0) {
        $params['http']['header'] = map_reduce($optional_headers, function($key, $value, $carry) { return "$carry$key: $value\r\n"; }, "" );
    }

    if (function_exists('curl_init')) {
        return bit_curl($method, $path, $data, $optional_headers);
    }

    $ctx = stream_context_create($params);
    $response = file_get_contents($path, false, $ctx);
    // log failed requests, but not failed requests to wordpress source code

    $m1 = microtime(true);
    $ms = round(($m1 - $m0) * 1000, 2);
    trace("http $path1 ({$ms}ms)");
    if ($response === false && !contains($path, "wordpress.org")) {
        return debugF("http_resp [%s] fail", $path);
    }

    return $response;
}

/**
 * create HTTP context for HTTP request
 * PURE
 */
function http_ctx(string $method, int $timeout) : array {
    return array('http' => array(
        'method' => $method,
        'timeout' => $timeout,
        'max_redirects' => 5,
        'header' => ''
        ),
        'ssl' => array(
            'verify_peer' => true,
            'allow_self_signed' => false,
        )
    );
}

/**
 * find the IP DB for a given IP
 * TODO: split into more files, improve distribution
 * PURE: IDEMPOTENT, REFERENTIAL INTEGRITY
 */
function ip_to_file(int $ip_num) : string {
    $n = floor($ip_num/0x5F5E100);
    return "cache/ip.$n.bin";
}


/**
 * ugly AF returns the country number
 * Need to reimplement as Binary Search
 * depends on IP DB
 * NOT PURE, should this be refactored to FileData ?
 */
function ip_to_country(?string $ip) : int {
    if (empty($ip) || preg_match("/^(127\.|10\.|192\.168)/", $ip)) { return 0; }
    $n = ip2long($ip);
    if ($n === false) { return 0; }
    $d = file_get_contents(\BitFire\WAF_ROOT.ip_to_file($n));
    $len = strlen($d);
    $off = 0;
    // binary search here. this list should be ordered
    while ($off < $len) {
        $data = unpack("Vs/Ve/Cc", $d, $off);
        if ($data['s'] <= $n && $data['e'] >= $n) { return $data['c']; }
        $off += 9;
    }
    return 0;
}


/**
 * call debug and return NULL
 */
function debugN(string $fmt, ...$args) : ?bool {
    debug($fmt, ...$args);
    return NULL;
}

/**
 * call debug and return FALSE
 */
function debugF(string $fmt, ...$args) : bool {
    debug($fmt, ...$args);
    return false;
}


function trace(?string $msg = null) : string {
    static $r = "";
    if ($msg == null) { return $r; }
    $r .= "$msg, ";
    return "";
}

/**
 * call the error handler.  This will create at most 1 new error entry in errors.json
 * @param null|string $fmt 
 * @param mixed $args 
 * @return void 
 * @throws RuntimeException 
 */
function error(?string $fmt, ...$args) : void {
    debug($fmt, ...$args);
    $line = str_replace(array("\r","\n",":"), array(" "," ",";"), sprintf($fmt, ...$args));
    $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
    $idx = isset($bt[1]) ? 1 : 0;
    \BitFire\on_err(-1, $line, $bt[$idx]["file"], $bt[$idx]["line"]);
    if (isset($bt[2])) {
        \BitFire\on_err(2, $line, $bt[2]["file"], $bt[2]["line"]);
    }
}

function format_chk(?string $fmt, int $args) : bool {
    if ($fmt == null) { return true; }
    return(substr_count($fmt, "%") === $args);
}

/**
 * add a line to the debug file (SLOW, does not wait until processing is complete)
 * NOT PURE
 */
function debug(?string $fmt, ...$args) : ?array {
    assert(class_exists('\BitFire\Config'), "programmer error, call debug() before config is loaded");
    assert(format_chk($fmt, count($args)), "programmer error, format string does not match number of arguments [$fmt]");
    // if (!format_chk($fmt, count($args))) { debug("programmer error, format string does not match number of arguments [$fmt]"); }

    static $idx = 0;
    static $len = 0;
    static $log = [];
    static $early_exit = -1; 

    // first call, figure out if we are exiting early. this executes 1 time
    if ($early_exit === -1) {
        $early_exit = (CFG::disabled("debug_file") && CFG::disabled("debug_header")) ? 1 : 0;
    }
    // if we are not debugging, return early
    if ($early_exit === 1 || empty($fmt)) {
        return (empty($fmt)) ? $log : null;
    }

    // ugly AF
    if ($fmt === "RETURN_LOG") { return $log; }

    // format any objects or arrays for debug
    foreach ($args as &$arg) { 
        if (is_array($arg) || is_object($arg)) { $arg = json_encode($arg, JSON_PRETTY_PRINT); }
        // else { $arg = str_replace("%", "%%", $arg); }
    }

    $line = "";
    // write debug to headers for quick debug
    if (CFG::enabled("debug_header")) {
        $line = str_replace(array("\r","\n",":"), array(" "," ",";"), @sprintf($fmt, ...$args));
        if (!headers_sent() && $idx < 24) {
            $s = sprintf("x-bf-%02d: %s", $idx, substr($line, 0, 1024));
            $len += strlen($s);
            if ($len < 4000) {
                header($s);
            }
        }
    }

    // write to file
    if (CFG::enabled("debug_file")) {
        if ($idx === 0) {
            register_shutdown_function(function () {
                $f = get_hidden_file("/debug.log");
                $log = debug("RETURN_LOG");
                $mode = (file_exists($f) && filesize($f) > 1024*1024*4) ? FILE_W : FILE_APPEND;
                file_put_contents($f, join("\n", $log), $mode);
            });
        }
        $line = sprintf($fmt, ...$args);
        if (starts_with($fmt, "ERROR")) {
            $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
            $b1 = isset($bt[2]) ? ($bt[2]['file']??'??'.':'.$bt[2]['line']??'??') : '';
            $b2 = isset($bt[3]) ? ($bt[3]['file']??'??'.':'.$bt[3]['line']??'??') : '';
            $line = "$line\n$b1\n$b2";
        }
        // if the file is >1MB overwrite it, else append
    }
    
    $idx++;
    if (!empty($line)) { $log[] = $line; }
    return null;
}




/**
 * sets a cookie in a browser in various versions of PHP
 * NOT PURE 
 */
function cookie(string $name, ?string $value, int $exp = DAY) : void {
    if (!CFG::enabled("cookies_enabled")) { debug("wont set cookie, disabled"); return; }
    if (headers_sent($file, $line)) { debug("unable to set cookie, headers already sent (%s:%d)", $file, $line); return; }
    if (PHP_VERSION_ID < 70300) { 
        setcookie($name, $value, time() + $exp, '/; samesite=strict', '', false, true);
    } else {
        setcookie($name, $value, [
            'expires' => time() + $exp,
            'path' => '/',
            'domain' => '',
            'secure' => false,
            'httponly' => true,
            'samesite' => 'strict'
        ]);
    }
}


/**
 * sort profiling data by wall time, used to profile performance
 * PURE: IDEMPOTENT, REFERENTIAL INTEGRITY
 */
function prof_sort(array $a, array $b) : int {
    if ($a['wt'] == $b['wt']) { return 0; }
    return ($a['wt']??0 < $b['wt']??0) ? -1 : 1;
}


/**
 * replace file contents inline, $find can be a regex or string
 */
function file_replace(string $filename, string $find, string $replace, int $mode = 0) : Effect {
    $fn_name = ($find[0] == "/") ? "preg_replace" : "str_replace";
    $fn = partial($fn_name, $find, $replace);

    //$x = FileData::new($filename)->read()->map($fn)->file_mod($mode);
    $file_mod = FileData::new($filename)->read()->map($fn)->file_mod($mode);
    return Effect::new()->file($file_mod);
}

// boolean to string (true|false) 
// PURE: IDEMPOTENT, REFERENTIAL INTEGRITY
// TODO: refactor to %t render format
function b2s(bool $input) :string {
    return ($input) ? "true" : "false";
}



/**
 * return an effect to create a ini_info.php file which sets
 * a variable $ini_type to the type of ini file used. we do
 * this here because some wordpress servers do not always
 * allow us to write php files on any request.
 * @return Effect 
 */
function make_config_loader() : Effect {
    $effect = Effect::new();
    if (defined("BitFire\WAF_INI")) { return $effect->out(\BitFire\WAF_INI)->hide_output(); }
    //file_put_contents("/tmp/foo.txt", print_r(get_defined_constants(), true));


    // FIRST, lets verify that we already have a valid config
    // if so we bail out early here...
    $parent = dirname(WAF_ROOT, 1);
    $file = FileData::new(\BitFire\WAF_ROOT."ini_info.php");
    if ($file->exists) {
        $secret_key = "";
        include $file->filename;
        $config_file = $parent . "/bitfire_{$secret_key}/config.ini";
        if (file_exists($config_file)) {
            define("BitFire\WAF_INI", $config_file);
            return $effect->out($config_file)->hide_output();
        }
    }
    // ini_info was invalid, reset the key
    $secret_key = "";

    // we don't know where the config is because there is no ini_info file
    // probably a first run, or a new install, lets find it
    // find all old configs
    $config_dirs = glob("{$parent}/bitfire_??????????");

    // get the creation/modification time so we can find most recent
    $dir_with_time = array_map(function($dir) {
        return [ "dir" => $dir, "time" => filemtime($dir) ];
    }, $config_dirs);
    usort($dir_with_time, function($a, $b) {
        return $a["time"] - $b["time"];
    });
    // if we have existing dirs, then lets use the most recent config
    if (count($dir_with_time) > 0) {
        $newest = array_pop($dir_with_time);
        if (preg_match("/bitfire_(\w+)/", $newest["dir"], $matches)) {
            $secret_key = $matches[1];
            while($next = array_pop($dir_with_time)) {
                // delete all but the newest
                $effect->unlink($next["dir"]);
            }
        }
    }
    // no old configs, lets create a new one
    if (empty($secret_key)) {
        $secret_key = random_str(10);
        // check if the hidden config has not yet been moved and move it
        $path = $parent . "/bitfire_{$secret_key}/";
        $orig_config = WAF_ROOT . "hidden_config";
        if (file_exists($orig_config)) {
            $iam = whoami();
            $script_is = fileowner($path);

            $success = rename($orig_config, $path);
            if (!$success) {
                echo "Unable to rename $orig_config to $path\n\n";
                echo "The $iam user needs write access to " . dirname($path) . " and $orig_config\n\n";
                if ($iam != $script_is) {
                    echo "Try: sudo chown -R $iam $orig_config\n\n";
                }
                die();
            }
        }
    }

    // we should have a secret key by now, lets update the ini_info file
    if (!empty($secret_key)) {
        $markup = "<?"."php \$secret_key = '$secret_key'; ";
        if (function_exists("shmop_open")) {
            $markup .= '$ini_type = "shmop";';
        } else if (function_exists("apcu_store")) {
            $markup .= '$ini_type = "acpu";';
        } else {
            $markup .= '$ini_type = "opcache";';
        }
        $effect->file(new FileMod(\BitFire\WAF_ROOT."ini_info.php", $markup));
    }

    $path = $parent . "/bitfire_{$secret_key}/";
    define("BitFire\WAF_INI", $path . "config.ini");
    return $effect->out($path . "config.ini")->hide_output();
}



/**
 * get the path to a hidden file
 * @param string $file_name the name of the file
 * @param (null|string)|null $secret_key - the secret key as stored in the ini_info.php file
 * @return string - the realpath to the file
 */
function get_hidden_file(string $file_name, ?string $secret_key = null) : string {
    static $path = null;
    if (php_sapi_name() === "cli") { return getcwd() . "/$file_name"; }

    // use the secret key passed to us
    if (!empty($secret_key)) {
        $parent = dirname(WAF_ROOT, 1);
        $path = realpath($parent . "/bitfire_{$secret_key}/") . "/";
    }
    // fall back to the secret key in the ini_info file
    if (empty($path)) {
        $path = dirname(make_config_loader()->read_out(), 1) . "/";
    }
    return $path . $file_name;
}

/**
 * load the config from the secret config location
 * @return array 
 * @throws RuntimeException 
 */
function parse_ini() : array {
    $ini_type = "opcache";

    $loader = make_config_loader()->run();
    $config_file = $loader->read_out();

    // get the ini file modification time
    $mod_time = filemtime($config_file);

    // load the config from the cache
    // $options is an array [$data, $mtime]
    $cache = CacheStorage::get_instance($ini_type);
    $config = $cache->load_or_cache("parse_ini", 3600, function() use ($config_file) {
        $config =  parse_ini_file($config_file, false, INI_SCANNER_TYPED);
        if (count($config) > 10) {
            $config['mtime'] = time();
            return $config;
        }
        return [];
    });

    if ($config['mtime'] < $mod_time) {
        $config = parse_ini_file($config_file, false, INI_SCANNER_TYPED);
        // ensure that passwords are always hashed
        $pass = $config['password']??'disabled';
        if (strlen($pass) < 39 && $pass != 'disabled' && $pass != 'configure') {
            $hashed = hash('sha3-256', $pass);
            $config['password'] = $hashed;
            require_once WAF_SRC . "server.php"; // make sure we have the correct function loaded
            update_ini_value('password', $hashed)->run();
        }
        $config['mtime'] = time();
        $cache->save_data('parse_ini', $config, 3600);
    }

    // if we have a pro key, then download the latest pro version of code
    check_pro_ver($config["pro_key"]??"");

    return $config;
}



/**
 * impure fetch pro code and install
 * @param string $pro_key 
 */
function check_pro_ver(string $pro_key) {
    // pro key and no pro files, download them UGLY, clean this!
    $profile = \BitFire\WAF_SRC . "proapi.php";
    if (strlen($pro_key) > 20 && (!file_exists($profile) || (file_exists($profile) && @filesize(\BitFire\WAF_SRC."proapi.php") < 512))) {
        trace("DWNPRO");
        $email = "unknown";
        $name = "unknown";
        if (defined("WPINC") && function_exists("wp_get_current_user")) {
            $user = \wp_get_current_user();
            $name = $user->user_firstname . " " . $user->user_lastname;
        }
        $out = \BitFire\WAF_SRC."pro.php";
        $content = http("POST", "https://bitfire.co/getpro.php", array("name" => $name, "email" => $email, "release" => \BitFire\BITFIRE_VER, "key" => $pro_key, "domain" => $_SERVER['SERVER_NAME'],"file" => "pro.php"));
        debug("downloaded pro code [%d] bytes", strlen($content));
        if ($content && strlen($content) > 512) {
            if (@file_put_contents($out, $content, LOCK_EX) !== strlen($content)) { debug("unable to write [%s]", $out); };
            $content = http("POST", "https://bitfire.co/getpro.php", array("name" => $name, "email" => $email, "release" => \BitFire\BITFIRE_VER, "key" => $pro_key, "domain" => $_SERVER['SERVER_NAME'], "file" => "proapi.php"));
            debug("downloaded proapi code [%d] bytes", strlen($content));
            $out = \BitFire\WAF_SRC."proapi.php";
            if ($content && strlen($content) > 100) {
                if (@file_put_contents($out, $content, LOCK_EX) !== strlen($content)) { debug("unable to write [%s]", $out); };
            }

            // write the command line scanner
            if (@file_put_contents($out, $content, LOCK_EX) !== strlen($content)) { debug("unable to write [%s]", $out); };
            $content = http("POST", "https://bitfire.co/getpro.php", array("name" => $name, "email" => $email, "release" => \BitFire\BITFIRE_VER, "key" => $pro_key, "domain" => $_SERVER['SERVER_NAME'], "file" => "scanner.php"));
            debug("downloaded scanner code [%d] bytes", strlen($content));
            $out = \BitFire\WAF_ROOT."scanner.php";
            if ($content && strlen($content) > 100) {
                if (@file_put_contents($out, $content, LOCK_EX) !== strlen($content)) { debug("unable to write [%s]", $out); };
            }
        }
    }
}

/**
 * take any function $fn and return a function that will accumulate a string and return the result
 * maintain a single string state variable
 * 
 * passing ACTION_RETURN to the returned function will return the accumulated result
 * passing ACTION_CLEAN to the returned function will reset the accumulator
 * @param callable $fn - should return the string to append to the accumulator
 * @return callable the accumulator function
 */
function accrue_list(callable $fn, string $catalog_name) : callable {
    assert(is_type($catalog_name, Catalog::class), "catalog_name must be a catalog");
    return function(...$args) use ($fn, $catalog_name) : ?Catalog {
        static $catalog = NULL;
        if ($catalog === NULL) { $catalog = new $catalog_name(); }

        if (isset($args[0])) {
            if ($args[0] === ACTION_RETURN) {
                return $catalog;
            } else if ($args[0] === ACTION_CLEAN) {
                $catalog = new $catalog_name();
                return NULL;
            }
        }
        $catalog->add($args[0], $fn(...$args));
        return NULL;
    };
}



/**
 * effect with cache prevention headers
 * PURE: IDEMPOTENT, REFERENTIAL INTEGRITY
 */
function cache_prevent() : Effect {
    $effect = new Effect();
    $effect->header("cache-control", "no-store, private, no-cache, max-age=0");
    $effect->header("expires", gmdate('D, d M Y H:i:s \G\M\T', 100000));
    return $effect;
}

// make sure the string $value is a type of $type
function is_type(string $value, string $type) : bool {
    $x = new $type();
    return $x instanceof $value;
}


// return date in utc time
function utc_date(string $format) : string {
    return date($format, utc_time());
}

function utc_time() : int {
    return time() + date('Z');
}

function utc_microtime() : float {
    return microtime(true) + intval(date('Z') * 1000);
}

function array_shuffle(array $in) : array {
    $out = array();
    while(($m = count($in))>0) {
        $t = array_splice($in, mt_rand(0, $m) , 1);
        $out[] = $t[0]??0;
    }
    return $out;
}

/**
 * returns a maybe with tracking data or an empty monad...
 * TODO: create test function
 * PURE!
 */
function decrypt_tracking_cookie(?string $cookie_data, string $encrypt_key, string $src_ip, string $agent) : MaybeStr {
    static $r = null;
    // don't bother decrypting if we have no cookie data
    if (empty($cookie_data)) { return MaybeStr::of(false); }
    if ($r === null) { $r = MaybeStr::of(false); }

    $r->do_if_not(function() use ($cookie_data, $encrypt_key, $src_ip, $agent) {

        return decrypt_ssl($encrypt_key, $cookie_data)
            ->then("ThreadFin\\un_json")
            ->keep_if(function($cookie) use ($src_ip, $agent) {
                if (!isset($cookie['wp']) && !isset($cookie['ip']) && !isset($cookie['lck']) && !isset($cookie['mfa'])) {
                    debug("invalid decrypted cookie [%s] ", var_export($cookie, true));
                    return false;
                } else if (isset($cookie['ip'])) {
                    $src_ip_crc = \BitFireBot\ip_to_int($src_ip);
                    $cookie_match = (is_array($cookie) && (intval($cookie['ip']??0) == intval($src_ip_crc)));
                    $time_good = ((intval($cookie['et']??0)) > time());
                    $agent_good = crc32($agent) == $cookie['ua'];
                    if (!$cookie_match) { debug("cookie ip does not match"); }
                    if (!$time_good) { debug("cookie expired"); }
                    if (!$agent_good) { debug("agent mismatch live: [%s] [%d] cookie:[%d]", $agent, crc32($agent), $cookie['ua']??0); }
                    return ($cookie_match && $time_good && $agent_good);
                } else { return true; }
            });
    });
    return $r;
}


// ugly but compatible with all versions of php
function call_to_source(string $fn, array $x, string $cost = "wt") : array {
    $file = '<internal>'; $line = -1;
    try {
        $o = null;
        if (strpos($fn, '::') !== false) {
            list($c, $f) = explode('::', $fn, 2);
            $o = new \ReflectionMethod($c, $f);
            $file = $o->getFileName();
            $line = $o->getStartLine();
        } else {
            $o = new \ReflectionFunction($fn);
            $file = $o->getFileName();
            if (!$file) { $file = "<internal>"; }
            $line = $o->getStartLine();
            if (!$line) { $line = 0; }
        }
    } catch (\ReflectionException $e) { $file = '<internal>'; $line = 0; }

    return array('line' => $line, 'fn' => $fn, 'file' => $file, 'calls' => array(), 'count' => $x['ct'], 'cost' => $x[$cost]);
}


/**
 * convert xhprof data into a call-grind file (/tmp/callgrind.out)
 * @param null|array $data 
 * @return void 
 */
function output_profile(?array $data, string $out_file = "/tmp/callgrind.out") : void {
    $pre  = "version: 1\ncreator: https://bitfire.co\ncmd: BitFire\npart: 1\npositions: line\nevents: Time\nsummary: ";

    $fn_list = array();
    array_walk($data, function($x, $fn_name) use (&$fn_list) {
        $parts = explode('==>', $fn_name);
        if (!isset($fn_list[$parts[0]])) {
            $call = call_to_source($parts[0], $x, "wt");
            $fn_list[$parts[0]] = $call;
        }
        if (count($parts) > 1) {
            $call = call_to_source($parts[1], $x, "wt");
            $fn_list[$parts[0]]['calls'][] = $call;
        }
    });

    $out = "";
    $sum = 0;
    array_walk($fn_list, function($x, $fn_name) use (&$out, &$sum) {
        $out .= sprintf("fl=%s\nfn=%s\n%d %d\n", $x['file'], $x['fn'], $x['line'], $x['cost']);
        foreach ($x['calls'] as $call) {
            $out .= sprintf("cfl=%s\ncfn=%s\ncalls=%d %d\n%d %d\n", $call['file'], $call['fn'], $call['count'], $call['line'], $x['line'], $call['cost']);
            $sum += $call['cost'];
        }
        $out .= "\n";
    });

    file_put_contents($out_file, $pre . $sum . "\n\n". $out);
    return;
}

/**
 * @depends CFG:cms_root, cms_content_dir, cms_content_url, _SERVER: DOCUMENT_ROOT
 * @return string URL path to the public folder
 */
function get_public(?string $path = null) : string {
    // try and find the path to the public folder ourself (for standalone installs)
    $public = realpath(__DIR__ . "/../public/$path").DS;
    $dr = realpath($_SERVER['DOCUMENT_ROOT']??".");
    $public = str_replace($dr, "", $public);
    // if we have a cms configuration, use that
    if (CFG::enabled("cms_root")) {
        $path = ($path === null) ? "" : $path;
        if (file_exists(CFG::str("cms_content_dir") . "/plugins/bitfire/public/$path")) {
            $public = CFG::str("cms_content_url")."/plugins/bitfire/public/$path";
        }
    }
    return $public;
}

function _b(string $text, $before = "") : string {
    return (string)$before . _t($text);
}

function _t(string $text) : string {
    return $text;
}

/**
 * @param mixed $data 
 * @return array [compressed_data, uncompressed_size, type]
 */
function compress($data) : array {
    $type = "serialize";
    // we have a header we can write cache data to...
    if (function_exists('\igbinary_serialize')) {
        $compress = \igbinary_serialize($data);
        $type = "igbinary";
    } else if (function_exists('\msgpack_pack')) {
        $compress = \msgpack_pack($data);
        $type = "msgpack";
    } else {
        $compress = serialize($data);
    }
    return [base64_encode($compress), strlen($data), $type];
}

/**
 * return original data from compressed data
 * @param array $data 
 * @return mixed 
 */
function uncompress(array $data) {
    $compress = base64_decode($data[0]);
    switch ($data[2]) {
        case "igbinary":
            return \igbinary_unserialize($compress);
        case "msgpack":
            return \msgpack_unpack($compress);
        default:
            return unserialize($compress);
    }
}