yf_services_autoload class

Per-file lazy autoloader driven by services/<name>.lock.json (see yf_services_lock): class -> psr-4 / psr-0 / classmap index -> file under <vendor dir>/<vendor>/<package>/, fetched from raw.githubusercontent.com at the locked sha on first use when $ALLOW_RUNTIME_FETCH is on.

Fileclasses/yf_services_autoload.class.php
Use_class('services_autoload')

Public properties

A site sets them in its config with $PROJECT_CONF['services_autoload']['NAME'] = ...;

PropertyDefaultDescription
$ALLOW_RUNTIME_FETCH
bool
false fetch a missing class file at run time; off: a missing file is left to the next autoloader
$AUTO_REGISTER
bool
true register the shared spl_autoload_register() callback with the first lock
$COMPOSER_VENDOR_DIRS
null|array
those of YF_PATH, APP_PATH, PROJECT_PATH */ Property [ public $COMPOSER_VENDOR_DIRS = NULL vendor dirs whose composer/installed.json hides a package from the lock; null = those of YF_PATH, APP_PATH, PROJECT_PATH
$FETCHER
null|callable
null fn(string): string|false, the body of a 200 answer; default: the HTTPS fetch of http_get()
$FETCH_LOG
string
off); services:fetch --warm-from replays it */ Property [ public $FETCH_LOG = '' append one "loader<TAB>package<TAB>path" line per fetched file here ('' = off); services:fetch --warm-from replays it
$FETCH_TIMEOUT
int
10 seconds per HTTP request of the default fetcher
$INCLUDE_EAGER
bool
true include the `files` entries at register time; services:fetch turns it off to index a lock without running it
$MAX_FILE_SIZE
int
8388608 largest file the default fetcher accepts, bytes
$PROXY
string
the HTTPS_PROXY environment variable */ Property [ public $PROXY = '' proxy for the default fetcher, "tcp://host:port"; '' = the HTTPS_PROXY environment variable
$USER_AGENT
string
'yf-services (+https://github.com/yfix/yf)'
$_dirty false
$_installed
array
[] vendor dir => list of package names in its composer/installed.json
$_missed
array
[] "package/path" => true, a fetch that failed in this request
$_noticed
array
[] package => true, one notice per package and request
$_registered false
$classmap
array
[] lower case class => [package, path]
$loaders
array
[] loader name => ['vendor_dir' => string, 'packages' => list of package names]
$packages
array
[] package => ['loader', 'vendor_dir', 'fallback_dir', 'url', 'reference', 'files_sha256']
$psr0
array
[] prefix => list of [package, dir]
$psr4
array
[] prefix => list of [package, dir]

Public methods 15

candidates

public function candidates($class): array

Every [package, relative path, fetchable] that may hold the class, best match first.

  • $class string
    public function candidates($class): array
    {
        $class = ltrim((string) $class, '\\');
        if ($class === '' || ! preg_match('~^[A-Za-z0-9_\x80-\xff\\\\]+$~D', $class)) {
            return [];
        }
        $this->sort_prefixes();
        $out = [];
        $hit = $this->classmap[strtolower($class)] ?? null;
        if ($hit) {
            $out[] = [$hit[0], $hit[1], true];
        }
        foreach ($this->psr4 as $prefix => $rules) {
            if ($prefix !== '' && strncmp($class, $prefix, strlen($prefix)) !== 0) {
                continue;
            }
            $rest = str_replace('\\', '/', substr($class, strlen($prefix)));
            foreach ($rules as [$package, $dir]) {
                $out[] = [$package, $dir . $rest . '.php', $prefix !== ''];
            }
        }
        foreach ($this->psr0 as $prefix => $rules) {
            if ($prefix !== '' && strncmp($class, $prefix, strlen($prefix)) !== 0) {
                continue;
            }
            $pos = strrpos($class, '\\');
            if ($pos !== false) {
                $logical = str_replace('\\', '/', substr($class, 0, $pos + 1)) . str_replace('_', '/', substr($class, $pos + 1));
            } else {
                $logical = str_replace('_', '/', $class);
            }
            foreach ($rules as [$package, $dir]) {
                $out[] = [$package, $dir . $logical . '.php', $prefix !== ''];
            }
        }
        return $out;
    }

composer_has

public function composer_has($package, $vendor_dir): bool
  • $package string
  • $vendor_dir string
    public function composer_has($package, $vendor_dir): bool
    {
        $dirs = $this->COMPOSER_VENDOR_DIRS;
        if ($dirs === null) {
            $dirs = [];
            foreach (['YF_PATH', 'APP_PATH', 'PROJECT_PATH'] as $const) {
                if (defined($const) && strlen((string) constant($const))) {
                    $dirs[] = rtrim((string) constant($const), '/') . '/vendor/';
                }
            }
            $dirs[] = $vendor_dir;
        }
        foreach (array_unique($dirs) as $dir) {
            $dir = rtrim($dir, '/') . '/';
            if ( ! isset($this->_installed[$dir])) {
                $names = [];
                $file = $dir . 'composer/installed.json';
                $data = is_file($file) ? json_decode((string) file_get_contents($file), true, 64) : null;
                foreach ((array) (is_array($data) && isset($data['packages']) ? $data['packages'] : $data) as $item) {
                    if (is_array($item) && isset($item['name'])) {
                        $names[strtolower((string) $item['name'])] = true;
                    }
                }
                $this->_installed[$dir] = $names;
            }
            if (isset($this->_installed[$dir][$package])) {
                return true;
            }
        }
        return false;
    }

ensure_file

public function ensure_file($package, $path): bool

The file is on disk afterwards (fetched when missing and allowed).

  • $package string
  • $path string
    public function ensure_file($package, $path): bool
    {
        $file = $this->file_path($package, $path);
        if ($file === '') {
            return false;
        }
        if (is_file($file)) {
            return true;
        }
        return $this->ALLOW_RUNTIME_FETCH && $this->fetch_file($package, $path);
    }

fetch_file

public function fetch_file($package, $path): bool

Fetch one file of a locked package from raw GitHub at its sha, write it atomically, record its sha256.

  • $package string
  • $path string relative to the package root
    public function fetch_file($package, $path): bool
    {
        $info = $this->packages[$package] ?? null;
        $file = $this->file_path($package, $path, false);
        if ( ! $info || $file === '') {
            return false;
        }
        $repo = $this->github_repo((string) $info['url']);
        if ( ! $repo || ! preg_match('~^[0-9a-f]{40}$~', (string) $info['reference'])) {
            if (empty($this->_noticed[$package])) {
                $this->_noticed[$package] = true;
                trigger_error('services_autoload: "' . $package . '" has no github.com source with a full sha in its lock, cannot fetch ' . $path, E_USER_NOTICE);
            }
            return false;
        }
        $url = 'https://raw.githubusercontent.com/' . $repo . '/' . $info['reference'] . '/' . implode('/', array_map('rawurlencode', explode('/', $path)));
        $body = $this->FETCHER ? ($this->FETCHER)($url) : $this->http_get($url);
        if ( ! is_string($body)) {
            return false;
        }
        $sha256 = hash('sha256', $body);
        $expected = $info['files_sha256'][$path] ?? null;
        $record_key = $package . '/' . $path;
        $record = $this->read_record($info['loader'], $info['vendor_dir']);
        $previous = $record[$record_key] ?? null;
        if (($expected !== null && ! hash_equals(strtolower((string) $expected), $sha256))
            || ($expected === null && $previous && ($previous[2] ?? '') === $info['reference'] && ! hash_equals((string) $previous[0], $sha256))) {
            trigger_error('services_autoload: sha256 mismatch for ' . $record_key . ' at ' . $info['reference'] . ', file not written', E_USER_WARNING);
            return false;
        }
        if ( ! $this->write_atomic($file, $body)) {
            return false;
        }
        $this->update_record($info['loader'], $info['vendor_dir'], [$record_key => [$sha256, time(), $info['reference']]]);
        if ($this->FETCH_LOG !== '') {
            @file_put_contents($this->FETCH_LOG, $info['loader'] . "\t" . $package . "\t" . $path . "\n", FILE_APPEND | LOCK_EX);
        }
        return true;
    }

file_path

public function file_path($package, $path, $read = true): string

Absolute path of a package file, '' when the package is unknown or the path is not a clean relative one.

  • $package string
  • $path string
  • $read bool false: the path a fetch writes to, never the fallback dir
    public function file_path($package, $path, $read = true): string
    {
        $info = $this->packages[$package] ?? null;
        if ( ! $info || ! $this->valid_path($path)) {
            return '';
        }
        $file = $info['vendor_dir'] . $package . '/' . $path;
        $fallback = (string) ($info['fallback_dir'] ?? '');
        if ($read && $fallback !== '' && ! is_file($file) && is_file($fallback . $package . '/' . $path)) {
            return $fallback . $package . '/' . $path;
        }
        return $file;
    }

github_repo

public function github_repo($url): string

"owner/repo" of a github.com clone url (https, git@, ssh://, git://), '' for anything else.

  • $url string
    public function github_repo($url): string
    {
        if (preg_match('~^(?:https://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/|git://github\.com/)([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$~', trim((string) $url), $m)) {
            if (trim($m[1], '.') !== '' && trim($m[2], '.') !== '') {
                return $m[1] . '/' . $m[2];
            }
        }
        return '';
    }

http_get

public function http_get($url)

Default fetcher: one HTTPS GET, the body of a 200 answer or false.

  • $url string

Returns false|string

    public function http_get($url)
    {
        $http = [
            'method' => 'GET',
            'timeout' => (float) $this->FETCH_TIMEOUT,
            'user_agent' => $this->USER_AGENT,
            'follow_location' => 0,
            'ignore_errors' => true,
        ];
        $proxy = $this->PROXY !== '' ? $this->PROXY : (string) (getenv('HTTPS_PROXY') ?: getenv('https_proxy'));
        if ($proxy !== '') {
            $parts = parse_url($proxy);
            if (isset($parts['host'])) {
                $http['proxy'] = 'tcp://' . $parts['host'] . ':' . ($parts['port'] ?? 80);
                $http['request_fulluri'] = false;
                if (isset($parts['user'])) {
                    $http['header'] = 'Proxy-Authorization: Basic ' . base64_encode(rawurldecode($parts['user']) . ':' . rawurldecode($parts['pass'] ?? ''));
                }
            }
        }
        $ssl = ['verify_peer' => true, 'verify_peer_name' => true];
        $cafile = (string) getenv('SSL_CERT_FILE');
        if ($cafile !== '' && is_file($cafile)) {
            $ssl['cafile'] = $cafile;
        }
        $fp = @fopen($url, 'rb', false, stream_context_create(['http' => $http, 'ssl' => $ssl]));
        if ( ! $fp) {
            return false;
        }
        stream_set_timeout($fp, (int) $this->FETCH_TIMEOUT);
        $status = 0;
        foreach ((array) (stream_get_meta_data($fp)['wrapper_data'] ?? []) as $line) {
            if (is_string($line) && preg_match('~^HTTP/\S+\s+(\d{3})~', $line, $m)) {
                $status = (int) $m[1];
            }
        }
        $body = $status === 200 ? stream_get_contents($fp, $this->MAX_FILE_SIZE + 1) : false;
        fclose($fp);
        return is_string($body) && strlen($body) <= $this->MAX_FILE_SIZE ? $body : false;
    }

include_file

public function include_file($package, $path): bool

require_once of a package file; a missing one is a notice, not a fatal error.

  • $package string
  • $path string
    public function include_file($package, $path): bool
    {
        if ( ! $this->ensure_file($package, $path)) {
            trigger_error('services_autoload: ' . $package . '/' . $path . ' is missing' . ($this->ALLOW_RUNTIME_FETCH ? ' and could not be fetched' : ', run services:fetch or set ALLOW_RUNTIME_FETCH'), E_USER_NOTICE);
            return false;
        }
        require_once $this->file_path($package, $path);
        return true;
    }

load

public function load($class): bool

spl_autoload callback.

  • $class string
    public function load($class): bool
    {
        $candidates = $this->candidates($class);
        if ( ! $candidates) {
            return false;
        }
        foreach ($candidates as [$package, $path]) {
            $file = $this->file_path($package, $path);
            if ($file !== '' && is_file($file)) {
                require_once $file;
                return true;
            }
        }
        if ( ! $this->ALLOW_RUNTIME_FETCH) {
            return false;
        }
        foreach ($candidates as [$package, $path, $fetchable]) {
            if ( ! $fetchable || isset($this->_missed[$package . '/' . $path])) {
                continue;
            }
            if ($this->fetch_file($package, $path)) {
                require_once $this->file_path($package, $path);
                return true;
            }
            $this->_missed[$package . '/' . $path] = true;
        }
        return false;
    }

record_files

public function record_files($package, array $paths): int

Record the sha256 of files that are already on disk (copied by services:fetch).

  • $package string
  • $paths array
    public function record_files($package, array $paths): int
    {
        $info = $this->packages[$package] ?? null;
        if ( ! $info) {
            return 0;
        }
        $set = [];
        foreach ($paths as $path) {
            $file = $this->file_path($package, $path, false);
            if ($file !== '' && is_file($file)) {
                $set[$package . '/' . $path] = [hash_file('sha256', $file), time(), $info['reference']];
            }
        }
        $set && $this->update_record($info['loader'], $info['vendor_dir'], $set);
        return count($set);
    }

register_lock

public function register_lock($loader_name, array $lock, $vendor_dir, $fallback_dir = '')

Index the packages of a lock, include its eager files and resources, hook the shared autoloader.

  • $loader_name string
  • $lock array
  • $vendor_dir string fetched files are written here
  • $fallback_dir string read-only: a file missing in $vendor_dir is read from here when it exists ('' = none)

Returns array names of the packages this loader serves (composer-installed ones are skipped)

    public function register_lock($loader_name, array $lock, $vendor_dir, $fallback_dir = '')
    {
        $loader_name = (string) $loader_name;
        $vendor_dir = rtrim((string) $vendor_dir, '/') . '/';
        $fallback_dir = (string) $fallback_dir === '' ? '' : rtrim((string) $fallback_dir, '/') . '/';
        $served = [];
        $entries = [];
        foreach ((array) ($lock['packages'] ?? []) as $package => $entry) {
            $package = strtolower((string) $package);
            if ( ! $this->valid_package($package) || ! is_array($entry) || $this->composer_has($package, $vendor_dir)) {
                continue;
            }
            $served[] = $package;
            // eager files of a package that another lock registered first are still included (a converted lock may list only `files`)
            $entries[$package] = $entry;
            if (isset($this->packages[$package])) {
                continue;
            }
            [$url, $reference] = $this->source_of($entry);
            $this->packages[$package] = [
                'loader' => $loader_name,
                'vendor_dir' => $vendor_dir,
                'fallback_dir' => $fallback_dir,
                'url' => $url,
                'reference' => $reference,
                'files_sha256' => (array) ($entry['files_sha256'] ?? []),
            ];
            $this->index_package($package, (array) ($entry['autoload'] ?? []));
        }
        $this->loaders[$loader_name] = ['vendor_dir' => $vendor_dir, 'packages' => $served];
        $this->_dirty = true;
        if ($this->AUTO_REGISTER && ! $this->_registered) {
            // appended, never prepended: composer's autoloader keeps precedence
            spl_autoload_register([$this, 'load']);
            $this->_registered = true;
        }
        foreach ($this->INCLUDE_EAGER ? $entries : [] as $package => $entry) {
            foreach ((array) ($entry['autoload']['files'] ?? []) as $path) {
                $this->include_file($package, $path);
            }
            foreach ((array) ($entry['resources_files'] ?? []) as $path) {
                $this->ensure_file($package, $path);
            }
        }
        return $served;
    }

resolve

public function resolve($class)

First candidate of a class: [package, relative path] or null.

  • $class string
    public function resolve($class)
    {
        $first = $this->candidates($class)[0] ?? null;
        return $first ? [$first[0], $first[1]] : null;
    }

unregister

public function unregister()
    public function unregister()
    {
        if ($this->_registered) {
            spl_autoload_unregister([$this, 'load']);
            $this->_registered = false;
        }
    }

valid_package

public function valid_package($package): bool
    public function valid_package($package): bool
    {
        return is_string($package) && preg_match('~^[a-z0-9_][a-z0-9_.-]*/[a-z0-9_][a-z0-9_.-]*$~D', $package) === 1 && strpos($package, '..') === false;
    }

valid_path

public function valid_path($path): bool

No "..", no absolute path, no empty or "." segment, no backslash or NUL.

    public function valid_path($path): bool
    {
        if ( ! is_string($path) || $path === '' || strpbrk($path, "\\\0:") !== false || $path[0] === '/' || preg_match('~[\x00-\x1f\x7f]~', $path)) {
            return false;
        }
        foreach (explode('/', $path) as $segment) {
            if ($segment === '' || $segment === '.' || $segment === '..') {
                return false;
            }
        }
        return true;
    }