Skip to content

Commit

Permalink
Add data directory finder to store GitHub token
Browse files Browse the repository at this point in the history
  • Loading branch information
bennothommo committed Mar 9, 2021
1 parent 9f14cd0 commit 3ba76da
Show file tree
Hide file tree
Showing 3 changed files with 407 additions and 47 deletions.
146 changes: 146 additions & 0 deletions src/Filesystem/DataDir.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php namespace Winter\Cli\Filesystem;

use Exception;
use Throwable;

/**
* Data directory handler.
*
* Allows for selection and processing of files within a specific data directory. The data directory should be kept
* in an OS-specified configuration directory.
*
* @since 0.2.2
* @author Ben Thomson
*/
class DataDir
{
/** @var array Paths to attempt to store in, in order of priority. */
protected $paths = [];

/** @var string Folder name */
protected $folderName = 'winter-cli';

/** @var string Fallback path, if the paths above do not exist */
protected $fallbackPath;

/**
* Constructor.
*/
public function __construct()
{
$this->paths = [
$_SERVER['HOME'] . DIRECTORY_SEPARATOR . '.config',
$_SERVER['HOME'] . DIRECTORY_SEPARATOR . '.local' . DIRECTORY_SEPARATOR . 'share',
$_SERVER['HOME'] . DIRECTORY_SEPARATOR . 'AppData' . DIRECTORY_SEPARATOR . 'Local'
];

$this->fallbackPath = $_SERVER['HOME'] . DIRECTORY_SEPARATOR . '.winter-cli';
}

/**
* Puts a file into the data directory.
*
* If successful, will return the path written to.
*
* @param string $path
* @param string $content
* @return string
*/
public function put(string $path, string $content)
{
$path = $this->resolvePath($path);

try {
$dir = dirname($path);

if (is_dir($dir) && !is_writeable($dir)) {
throw new Exception('Path not writable');
}

if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
throw new Exception('Directory not writable');
}

if (is_file($path) && !is_writeable($path)) {
throw new Exception('Path not writable');
}

file_put_contents($path, $content, LOCK_EX);
} catch (Throwable $e) {
throw new Exception('Unable to put file "' . $path . '", please check permissions.');
}

return $path;
}

/**
* Gets a file into the data directory.
*
* If the file does not exist, returns `false`.
*
* @param string $path
* @return string|bool
*/
public function get(string $path)
{
$path = $this->resolvePath($path);

if (!$path || !is_readable($path)) {
throw new Exception('Unable to get file "' . $path . '", please check permissions.');
}

return file_get_contents($path);
}

/**
* Find the first available data directory.
*
* If none exist, try to create the fallback directory and return that. If it can't be created, an exception will
* be thrown.
*
* @return string
* @throws Exception If a data directory is unavailable, and the fallback path cannot be created.
*/
protected function getDirectory(): string
{
foreach ($this->paths as $dir) {
if (!is_dir($dir) || !is_writable($dir)) {
continue;
}

return $dir . DIRECTORY_SEPARATOR . $this->folderName;
}

if (!is_dir($this->fallbackPath)) {
if (!mkdir($this->fallbackPath, 0755, true)) {
throw new Exception('Unable to provide a data directory for use.');
}
}

return $this->fallbackPath;
}

/**
* Resolves a path to the data directory.
*
* Returns the resolved path if available, otherwise, returns `false`.
*
* @param string $path
* @return string|bool
*/
protected function resolvePath(string $path)
{
$root = $this->getDirectory();
$fullPath = PathResolver::resolve($root . DIRECTORY_SEPARATOR . $path);

if (!$fullPath) {
return false;
}

if (!PathResolver::within($fullPath, $root)) {
return false;
}

return $fullPath;
}
}
247 changes: 247 additions & 0 deletions src/Filesystem/PathResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<?php namespace Winter\Cli\Filesystem;

use Throwable;

/**
* A utility to resolve paths to their canonical location and handle path queries.
*
* Borrowed from https://github.com/wintercms/storm/blob/develop/src/Filesystem/PathResolver.php
*
* @since 0.2.2
* @author Ben Thomson
*/
class PathResolver
{
/**
* Resolves a path to its canonical location.
*
* This expands all symbolic links and resolves references to /./, /../ and extra / characters in the input path
* and returns the canonicalized absolute pathname.
*
* This function operates very similar to the PHP `realpath` function, except it will also work for missing files
* and directories.
*
* Returns canonical path if it can be resolved, otherwise `false`.
*
* @param string $path The path to resolve
* @return string|bool
*/
public static function resolve($path)
{
// Check if path is within any "open_basedir" restrictions
if (!static::withinOpenBaseDir($path)) {
return false;
}

// Split path into segments
$pathSegments = explode('/', static::normalize($path));

// Store Windows drive, if available, for final resolved path.
$drive = array_shift($pathSegments) ?: null;

$resolvedSegments = [];

foreach ($pathSegments as $i => $segment) {
// Ignore current directory markers or empty segments
if ($segment === '' || $segment === '.') {
continue;
}

// Traverse back one segment in the resolved segments
if ($segment === '..' && count($resolvedSegments)) {
array_pop($resolvedSegments);
continue;
}

$currentPath = ($drive ?? '')
. '/'
. ((count($resolvedSegments))
? implode('/', $resolvedSegments) . '/'
: '')
. $segment;

/**
* We'll check to see if the current path is within "open_basedir" restrictions. Given that the full path
* IS within the restrictions at this point - if the current path is not, we'll assume it makes up part of
* the path and add it as a resolved segment.
*/
if (static::withinOpenBaseDir($currentPath)) {
try {
if (is_link($currentPath)) {
// Resolve the symlink and replace the resolved segments with the symlink's segments
$resolvedSymlink = static::resolveSymlink($currentPath);
if (!$resolvedSymlink) {
return false;
}

$resolvedSegments = explode('/', $resolvedSymlink);
$drive = array_shift($resolvedSegments) ?: null;
continue;
} elseif (is_file($currentPath) && $i < (count($pathSegments) - 1)) {
// If we've hit a file and we're trying to relatively traverse the path further, we need to fail at this
// point.
return false;
}
} catch (Throwable $e) {
if (str_contains($e->getMessage(), 'open_basedir')) {
return false;
}
}
}

$resolvedSegments[] = $segment;
}

// Generate final resolved path, removing any leftover empty segments
return
($drive ?? '')
. DIRECTORY_SEPARATOR
. implode(DIRECTORY_SEPARATOR, array_filter($resolvedSegments, function ($item) {
return $item !== '';
}));
}

/**
* Determines if the path is within the given directory.
*
* @param string $path
* @param string $directory
* @return bool
*/
public static function within($path, $directory)
{
$directory = static::resolve($directory);
$path = static::resolve($path);

return static::startsWith($path, $directory);
}

/**
* Join two paths, making sure they use the correct directory separators.
*
* @param string $prefix
* @param string $path The path to add to the prefix.
* @return string
*/
public static function join($prefix, $path = '')
{
$fullPath = rtrim(static::normalize($prefix, false) . '/' . static::normalize($path, false), '/');

return static::resolve($fullPath);
}

/**
* Normalizes a given path.
*
* Converts any type of path (Unix or Windows) into a Unix-style path, so that we have a consistent format to work
* with internally. All paths will be returned with no trailing path separator.
*
* @param string $path
* @param bool $applyCwd If true, the current working directory will be appended if the path is relative.
* @return string
*/
protected static function normalize($path, $applyCwd = true)
{
// Change directory separators to Unix-based
$path = rtrim(str_replace('\\', '/', $path), '/');

if ($applyCwd) {
// Determine drive letter for Windows paths
$drive = (preg_match('/^([A-Z]:)/', $path, $matches) === 1)
? $matches[1]
: null;

// Prepend current working directory for relative paths
if (substr($path, 0, 1) !== '/' && is_null($drive)) {
$path = static::normalize(getcwd()) . '/' . $path;
}
}

return $path;
}

/**
* Standardizes the path separators of a path back to the expected separator for the operating system.
*
* @param string $path
* @return string
*/
public static function standardize($path)
{
return str_replace('/', DIRECTORY_SEPARATOR, static::normalize($path, false));
}

/**
* Resolves a symlink target.
*
* @param mixed $path The symlink source's path.
* @return string|bool
*/
protected static function resolveSymlink($symlink)
{
// Check that the symlink is valid and the target exists
$stat = linkinfo($symlink);
if ($stat === -1 || $stat === false) {
return false;
}

$target = readlink($symlink);

// If "open_basedir" restrictions are in effect, we will not allow symlinks that target outside the
// restrictions.
if (!$target || !static::withinOpenBaseDir($target)) {
return false;
}

$targetDrive = (preg_match('/^([A-Z]:)/', $symlink, $matches) === 1)
? $matches[1]
: null;

if (substr($target, 0, 1) !== '/' && is_null($targetDrive)) {
// Append the target in place of the symlink if it is a relative symlink
$directory = substr($symlink, 0, strrpos($symlink, '/') + 1);
$target = static::resolve($directory . $target);
}

return static::normalize($target);
}

/**
* Checks if a given path is within "open_basedir" restrictions.
*
* @param string $path
* @return bool
*/
protected static function withinOpenBaseDir($path)
{
$baseDirs = ini_get('open_basedir');

if (!$baseDirs) {
return true;
}

$baseDirs = explode(PATH_SEPARATOR, $baseDirs);
$found = false;

foreach ($baseDirs as $baseDir) {
if (static::startsWith(static::normalize($path), static::normalize($baseDir))) {
$found = true;
break;
}
}

return $found;
}

/**
* "starts_with" Laravel helper polyfill.
*
* @param string $haystack
* @param string $needle
* @return boolean
*/
protected static function startsWith(string $haystack, string $needle): bool
{
return stripos($haystack, $needle) === 0;
}
}
Loading

0 comments on commit 3ba76da

Please sign in to comment.