tc-lib-file

Technical overview and integration notes for tc-lib-file

Overview

tc-lib-file provides low-level file access and byte-reading primitives used by higher-level PDF and document libraries.

It is a small package doing an outsized job. Every host allowlist, path allowlist, size limit, and transport control that the rest of the stack relies on is enforced here, in one place that can be audited on its own.

Repository and API Docs

Project Metadata

ItemValue
Namespace\Com\Tecnick\File
LicenseGNU LGPL v3

Installation

composer require tecnickcom/tc-lib-file

Where It Fits

Wherever an input can arrive as a filesystem path, a data URI, a remote URL, or a raw string, and the code handling it should not have to care which.

Features

File Access

  • Local and URL-backed file reading helpers
  • Path-safety checks for local operations
  • cURL-based retrieval options for remote resources

Binary Utilities

  • Byte, integer, and structured binary reads
  • Helpers used by parser and image/font import stacks
  • Error handling through typed exceptions

Main Classes

ClassPurpose
\Com\Tecnick\File\FileLocal and remote reads behind host/path allowlists
\Com\Tecnick\File\ByteBig-endian byte-level reads from a binary string
\Com\Tecnick\File\CachePrefixed temporary file cache
\Com\Tecnick\File\DirWritable parent-directory lookup
\Com\Tecnick\File\ExceptionLibrary exception type

Security Configuration (Required)

File defaults to strict-deny behavior for both host and path validation:

  • allowedHosts defaults to an empty array, so remote URLs and host-based alternate path resolution are rejected unless you explicitly trust hosts.
  • allowedPaths defaults to an empty array, so local file operations are rejected unless you explicitly trust path prefixes.

Always pass explicit allowlists in the constructor (or set them immediately through the setters) in production:

$file = new \Com\Tecnick\File\File(
    allowedHosts: ['example.com'],
    allowedPaths: ['/srv/my-app/data'],
);

// Equivalent runtime configuration:
$file
    ->setAllowedHosts(['example.com'])
    ->setAllowedPaths(['/srv/my-app/data']);

Avoid wildcard trust ('*') unless you fully control every input and deployment boundary.

Allowlist entries are normalized once, when they are set:

  • Hosts are matched case-insensitively (per RFC 4343) and a trailing root dot is ignored, so example.com, EXAMPLE.COM, and example.com. are the same entry. An entry may take either of two forms:

    • example.com constrains the host only and matches it on any port.
    • example.com:8080 names one origin and matches only that port. This is the form a non-default HTTP_HOST value needs, and it authorizes https://example.com:8080/... too. A URL that omits the port is matched against the scheme default, so example.com:443 matches https://example.com/.

    An IPv6 host must use the bracketed form ([::1], [::1]:8080), and an internationalized domain must be listed as its A-label (punycode) form.

  • Paths are stored in both their literal and canonical (realpath()) form, so a root that traverses a symlink still matches files inside it. This matters on macOS (/tmp and /var are symlinks into /private), in containers, and with release-directory symlinks such as current -> releases/42. Redundant separators and . segments are collapsed, so /srv//data and /srv/./data name the same root.

Validating Without Side Effects

isValidURL() and isValidFile() take their argument by reference and rewrite it (trimming, and adding the file:// scheme), so they only accept a variable. Use the by-value counterparts for a literal or any other expression:

$file->isAllowedUrl('https://example.com/logo.png'); // bool, argument untouched
$file->isAllowedFile('/srv/my-app/data/report.pdf'); // bool, argument untouched

Both validators reject rather than raise for input that no path or URL function accepts, so they keep their bool contract for any string a caller can supply:

  • A path containing a NUL byte is invalid. PHP throws a ValueError for one in any path argument, so isValidFile(), resolveLocalPath() and every reader that goes through them report it as “not valid” or “unreadable” instead.
  • A URL containing a C0 control character or DEL is invalid. parse_url() tolerates CR, LF and TAB inside a URL; they enable response splitting if a validated URL is later emitted into a header, so they are rejected here.

Remote Fetching

Remote URLs are always fetched with cURL, so the allow_url_fopen ini setting has no effect on getUrlData(), getFileData(), and fileGetContents(). The curl extension is required; allowedHosts is the only gate on which hosts can be reached.

A response is buffered in memory and is bounded by maxRemoteSize (default 50 MB), counted as the bytes that reach PHP memory rather than the bytes received, so a compressed response is measured by what it inflates to. A response declaring an oversize length is rejected before it is read. A cURL option that the ext-curl build does not recognize, or whose value it refuses, is reported as \Com\Tecnick\File\Exception rather than as the ValueError curl_setopt_array() raises or the silent false it returns.

The library reserves the cURL transfer callbacks it relies on: CURLOPT_WRITEFUNCTION, CURLOPT_PROGRESSFUNCTION, CURLOPT_XFERINFOFUNCTION, CURLOPT_NOPROGRESS and, with redirects enabled, CURLOPT_HEADERFUNCTION. Values supplied for those through setCurlOpts() are replaced.

The legacy FORCE_CURL constant is obsolete and ignored: code that still defines it behaves the same.

Redirect Handling

Redirect validation is driven by CURLOPT_MAXREDIRS:

  • CURLOPT_MAXREDIRS => 0 (default): libcurl refuses every redirect, and a 3xx response is reported as an unreadable URL.
  • CURLOPT_MAXREDIRS > 0: redirects are processed and each Location target is validated against allowedHosts before it is followed. A Location header on a response that is not a 3xx is ignored, since libcurl never acts on it there.

A redirect that is not followed is reported as a failure (false), never as content. This matters when open_basedir is set, because PHP then leaves CURLOPT_FOLLOWLOCATION off: the body of the 3xx response would otherwise be returned as the file.

$file = new \Com\Tecnick\File\File(
    allowedHosts: ['example.com', 'downloads.example.com'],
    curlopts: [
        CURLOPT_MAXREDIRS => 5,
    ],
);

Other Classes

Byte: byte-level reads from a binary string

Immutable reader for big-endian values, used by the font and image parsers. Every reader validates its bounds and throws \RangeException rather than returning a wrong value.

$byte = new \Com\Tecnick\File\Byte($binaryString);

$byte->getLength();     // int: string length in bytes
$byte->getByte(0);      // uint8
$byte->getUShort(0);    // uint16 (alias: getUFWord)
$byte->getShort(0);     //  int16 (alias: getFWord)
$byte->getULong(0);     // uint32
$byte->getLong(0);      //  int32
$byte->getFixed(0);     // float, 16.16 fixed-point

The 32-bit readers assume a 64-bit PHP build.

Cache: temporary file cache

Each instance owns a cache directory and a filename prefix. delete() only ever touches files carrying that instance’s prefix.

$cache = new \Com\Tecnick\File\Cache('myapp');   // null = random prefix
$cache->setCachePath('/var/cache/myapp');        // falls back to K_PATH_CACHE

$path = $cache->getNewFileName('image', 'logo'); // create a new cache file
file_put_contents($path, $data);

$cache->delete('image', 'logo');                 // one type/key pair
$cache->delete('image');                         // one type
$cache->delete();                                // every file for this prefix
$cache->deleteOlderThan(3600);                   // by age, in seconds

$cache->delete(null, 'logo');                    // throws: a key needs its type

A cache file is named prefix + type + key, so a key can only be matched once a type narrows the name. delete(null, $key) is rejected rather than silently treated as “delete everything for this prefix”. deleteOlderThan() rejects a negative age, which would put the cutoff in the future and delete every file for the prefix.

_ separates those three fields, so it is stripped from a prefix, type or key along with every other character outside [A-Za-z0-9-] (+ and / map to -, so a base64 prefix keeps its entropy). A prefix left empty by that stripping is replaced with a random one.

The cache directory is K_PATH_CACHE when set, otherwise upload_tmp_dir, otherwise the system temp directory. setCachePath() falls back to K_PATH_CACHE when the given path is a stream wrapper or is not a writable directory, and throws \Com\Tecnick\File\Exception if no usable directory can be resolved at all. That fallback is silent, so compare getCachePath() against what you passed if you need to know it happened.

$type and $key scope delete(); they do not address a file for retrieval. The generated name carries a random suffix drawn from the system CSPRNG, so keep the value getNewFileName() returned. The suffix also keeps two calls with the same type and key from colliding; getNewFileName() throws rather than return a file outside the configured cache directory or one that delete() could never reclaim.

Dir: writable parent-directory lookup

$dir = (new \Com\Tecnick\File\Dir())->findParentDir('cache', __DIR__);

Walks up from $dir looking for a writable directory named $name. A regular file of that name is skipped, so the returned path is always one a caller can write into. $name must be a plain directory name: an empty, absolute or separator-bearing name, and one containing a .. segment, is reported as “not found” rather than resolved, since the result would not be an ancestor of $dir. Returns the path with a trailing separator, or '' when there is no match up to the filesystem root. Paths outside an active open_basedir restriction are skipped rather than probed, so the search raises no warnings.

Exception

\Com\Tecnick\File\Exception extends \Exception; it is what every documented @throws in the library refers to. Byte is the exception: out-of-bounds reads throw the SPL \RangeException.

Cross-Platform Path Handling

The library runs on Linux, macOS, and Windows, and path validation adapts to the host filesystem:

  • Allowlist matching follows the filesystem case rules: case-sensitive on Linux, case-insensitive on Windows, and per-volume on macOS (the library probes the volume and falls back to case-insensitive when it cannot). Override with the caseSensitivePaths constructor argument or setCaseSensitivePaths() when auto-detection is wrong for your deployment:
$file = new \Com\Tecnick\File\File(
    allowedPaths: ['/srv/my-app/data'],
    caseSensitivePaths: true, // or false; null (default) = auto-detect
);

// Equivalent runtime configuration:
$file->setCaseSensitivePaths(true);
  • On macOS, paths are normalized to NFC before comparison when ext-intl is installed, so composed and decomposed Unicode names match consistently; without ext-intl the comparison degrades to a byte comparison.
  • fopenLocal() forces the binary stream flag, so byte-level reads are not altered by Windows text-mode CRLF translation.
  • Windows 8.3 short names, Alternate Data Streams, trailing dots/spaces, and reserved device names are intentionally not treated as canonical; UNC paths match only when explicitly allowlisted.

Requirements

  • PHP 8.2 or later
  • Extensions: curl, pcre
  • Optional extension: intl (enables Unicode NFC normalization when matching paths against the allowlist)
  • Composer

Development and Packaging

Every tool is a Composer dev dependency, so the whole gate runs without the Makefile:

composer install
composer run qa         # cs-check + analyse + test
composer run cs-fix     # format

The Makefile wraps the same commands and adds packaging (Linux only):

  • QA and local checks: make deps, make help, make qa
  • Packaging: make rpm, make deb

The remote-fetch tests start a local PHP built-in server on a free port. Set TC_LIB_FILE_SKIP_HTTP_SERVER=1 to skip them where loopback networking or proc_open() is unavailable.

Support and Contribution

Integration Notes

Set the host and path allowlists before anything untrusted reaches the loader. Both default to refusing, so an unconfigured helper fetches nothing remote and reads nothing outside the computed trusted roots.

Resolve assets through one adapter rather than scattering the calls. It makes the allowlist a single reviewable object, and it makes the loader trivial to stub in tests.

Do not trust the MIME type a source declares. Check what the bytes actually are before embedding them.

Example

<?php

require_once __DIR__ . '/vendor/autoload.php';

$file = new \Com\Tecnick\File\File(
    allowedHosts: ['example.com', 'cdn.example.com'],
    allowedPaths: [__DIR__, '/var/app/uploads'],
    curlopts: [
        CURLOPT_MAXREDIRS => 3,
    ],
);
$fh = $file->fopenLocal(__FILE__, 'rb');
$header = $file->fReadInt($fh);

var_dump($header);