yf_services_autoload classPer-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.
| File | classes/yf_services_autoload.class.php |
|---|---|
| Use | _class('services_autoload') |
A site sets them in its config with $PROJECT_CONF['services_autoload']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$ALLOW_RUNTIME_FETCHbool |
false |
fetch a missing class file at run time; off: a missing file is left to the next autoloader |
$AUTO_REGISTERbool |
true |
register the shared spl_autoload_register() callback with the first lock |
$COMPOSER_VENDOR_DIRSnull|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 |
$FETCHERnull|callable |
null |
fn(string): string|false, the body of a 200 answer; default: the HTTPS fetch of http_get() |
$FETCH_LOGstring |
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_TIMEOUTint |
10 |
seconds per HTTP request of the default fetcher |
$INCLUDE_EAGERbool |
true |
include the `files` entries at register time; services:fetch turns it off to index a lock without running it |
$MAX_FILE_SIZEint |
8388608 |
largest file the default fetcher accepts, bytes |
$PROXYstring |
the HTTPS_PROXY environment variable */
Property [ public $PROXY = '' |
proxy for the default fetcher, "tcp://host:port"; '' = the HTTPS_PROXY environment variable |
$USER_AGENTstring |
'yf-services (+https://github.com/yfix/yf)' |
|
$_dirty |
false |
|
$_installedarray |
[] |
vendor dir => list of package names in its composer/installed.json |
$_missedarray |
[] |
"package/path" => true, a fetch that failed in this request |
$_noticedarray |
[] |
package => true, one notice per package and request |
$_registered |
false |
|
$classmaparray |
[] |
lower case class => [package, path] |
$loadersarray |
[] |
loader name => ['vendor_dir' => string, 'packages' => list of package names] |
$packagesarray |
[] |
package => ['loader', 'vendor_dir', 'fallback_dir', 'url', 'reference', 'files_sha256'] |
$psr0array |
[] |
prefix => list of [package, dir] |
$psr4array |
[] |
prefix => list of [package, dir] |
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;
}
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;
}
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);
}
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;
}
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;
}
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 '';
}
public function http_get($url)
Default fetcher: one HTTPS GET, the body of a 200 answer or false.
$url stringReturns 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;
}
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;
}
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;
}
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);
}
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;
}
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;
}
public function unregister()
public function unregister()
{
if ($this->_registered) {
spl_autoload_unregister([$this, 'load']);
$this->_registered = false;
}
}
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;
}
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;
}