<?php
/**
 * Part of the Joomla Framework Filesystem Package
 *
 * @copyright  Copyright (C) 2005 - 2021 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\Filesystem;

use Joomla\Filesystem\Exception\FilesystemException;

/**
 * A Path handling class
 *
 * @since  1.0
 */
class Path
{
	/**
	 * Checks if a path's permissions can be changed.
	 *
	 * @param   string  $path  Path to check.
	 *
	 * @return  boolean  True if path can have mode changed.
	 *
	 * @since   1.0
	 */
	public static function canChmod($path)
	{
		if (!file_exists($path))
		{
			return false;
		}

		$perms = @fileperms($path);

		if ($perms !== false)
		{
			if (@chmod($path, $perms ^ 0001))
			{
				@chmod($path, $perms);

				return true;
			}
		}

		return false;
	}

	/**
	 * Chmods files and directories recursively to given permissions.
	 *
	 * @param   string  $path        Root path to begin changing mode [without trailing slash].
	 * @param   string  $filemode    Octal representation of the value to change file mode to [null = no change].
	 * @param   string  $foldermode  Octal representation of the value to change folder mode to [null = no change].
	 *
	 * @return  boolean  True if successful [one fail means the whole operation failed].
	 *
	 * @since   1.0
	 */
	public static function setPermissions($path, $filemode = '0644', $foldermode = '0755')
	{
		// Initialise return value
		$ret = true;

		if (is_dir($path))
		{
			$dh = @opendir($path);

			if ($dh)
			{
				while ($file = readdir($dh))
				{
					if ($file != '.' && $file != '..')
					{
						$fullpath = $path . '/' . $file;

						if (is_dir($fullpath))
						{
							if (!static::setPermissions($fullpath, $filemode, $foldermode))
							{
								$ret = false;
							}
						}
						else
						{
							if (isset($filemode))
							{
								if (!static::canChmod($fullpath) || !@ chmod($fullpath, octdec($filemode)))
								{
									$ret = false;
								}
							}
						}
					}
				}

				closedir($dh);
			}

			if (isset($foldermode))
			{
				if (!static::canChmod($path) || !@ chmod($path, octdec($foldermode)))
				{
					$ret = false;
				}
			}
		}
		else
		{
			if (isset($filemode))
			{
				if (!static::canChmod($path) || !@ chmod($path, octdec($filemode)))
				{
					$ret = false;
				}
			}
		}

		return $ret;
	}

	/**
	 * Get the permissions of the file/folder at a give path.
	 *
	 * @param   string  $path  The path of a file/folder.
	 *
	 * @return  string  Filesystem permissions.
	 *
	 * @since   1.0
	 */
	public static function getPermissions($path)
	{
		$path = self::clean($path);
		$mode = @ decoct(@ fileperms($path) & 0777);

		if (\strlen($mode) < 3)
		{
			return '---------';
		}

		$parsedMode = '';

		for ($i = 0; $i < 3; $i++)
		{
			// Read
			$parsedMode .= ($mode[$i] & 04) ? 'r' : '-';

			// Write
			$parsedMode .= ($mode[$i] & 02) ? 'w' : '-';

			// Execute
			$parsedMode .= ($mode[$i] & 01) ? 'x' : '-';
		}

		return $parsedMode;
	}

	/**
	 * Checks for snooping outside of the file system root.
	 *
	 * @param   string  $path      A file system path to check.
	 * @param   string  $basePath  The base path of the system
	 *
	 * @return  string  A cleaned version of the path or exit on error.
	 *
	 * @since   1.0
	 * @throws  FilesystemException
	 */
	public static function check($path, $basePath = '')
	{
		if (strpos($path, '..') !== false)
		{
			throw new FilesystemException(
				sprintf(
					'%s() - Use of relative paths not permitted',
					__METHOD__
				),
				20
			);
		}

		$path = static::clean($path);

		// If a base path is defined then check the cleaned path is not outside of root
		if (($basePath != '') && strpos($path, static::clean($basePath)) !== 0)
		{
			throw new FilesystemException(
				sprintf(
					'%1$s() - Snooping out of bounds @ %2$s',
					__METHOD__,
					$path
				),
				20
			);
		}

		return $path;
	}

	/**
	 * Function to strip additional / or \ in a path name.
	 *
	 * @param   string  $path  The path to clean.
	 * @param   string  $ds    Directory separator (optional).
	 *
	 * @return  string  The cleaned path.
	 *
	 * @since   1.0
	 * @throws  \UnexpectedValueException If $path is not a string.
	 */
	public static function clean($path, $ds = \DIRECTORY_SEPARATOR)
	{
		if (!\is_string($path))
		{
			throw new \InvalidArgumentException('You must specify a non-empty path to clean');
		}

		$stream = explode('://', $path, 2);
		$scheme = '';
		$path   = $stream[0];

		if (\count($stream) >= 2)
		{
			$scheme = $stream[0] . '://';
			$path   = $stream[1];
		}

		$path = trim($path);

		// Remove double slashes and backslashes and convert all slashes and backslashes to DIRECTORY_SEPARATOR
		// If dealing with a UNC path don't forget to prepend the path with a backslash.
		if (($ds == '\\') && ($path[0] == '\\') && ($path[1] == '\\'))
		{
			$path = '\\' . preg_replace('#[/\\\\]+#', $ds, $path);
		}
		else
		{
			$path = preg_replace('#[/\\\\]+#', $ds, $path);
		}

		return $scheme . $path;
	}

	/**
	 * Method to determine if script owns the path.
	 *
	 * @param   string  $path  Path to check ownership.
	 *
	 * @return  boolean  True if the php script owns the path passed.
	 *
	 * @since   1.0
	 */
	public static function isOwner($path)
	{
		$tmp = md5(random_bytes(16));
		$ssp = ini_get('session.save_path');

		// Try to find a writable directory
		$dir = is_writable('/tmp') ? '/tmp' : false;
		$dir = !$dir && is_writable('.') ? '.' : $dir;
		$dir = !$dir && is_writable($ssp) ? $ssp : $dir;

		if ($dir)
		{
			$test = $dir . '/' . $tmp;

			// Create the test file
			$blank = '';
			File::write($test, $blank, false);

			// Test ownership
			$return = fileowner($test) === fileowner($path);

			// Delete the test file
			File::delete($test);

			return $return;
		}

		return false;
	}

	/**
	 * Searches the directory paths for a given file.
	 *
	 * @param   mixed   $paths  A path string or array of path strings to search in
	 * @param   string  $file   The file name to look for.
	 *
	 * @return  string|boolean   The full path and file name for the target file, or boolean false if the file is not found in any of the paths.
	 *
	 * @since   1.0
	 */
	public static function find($paths, $file)
	{
		// Force to array
		if (!\is_array($paths) && !($paths instanceof \Iterator))
		{
			settype($paths, 'array');
		}

		// Start looping through the path set
		foreach ($paths as $path)
		{
			// Get the path to the file
			$fullname = $path . '/' . $file;

			// Is the path based on a stream?
			if (strpos($path, '://') === false)
			{
				// Not a stream, so do a realpath() to avoid directory
				// traversal attempts on the local file system.

				// Needed for substr() later
				$path     = realpath($path);
				$fullname = realpath($fullname);
			}

			/*
			 * The substr() check added to make sure that the realpath()
			 * results in a directory registered so that
			 * non-registered directories are not accessible via directory
			 * traversal attempts.
			 */
			if (file_exists($fullname) && substr($fullname, 0, \strlen($path)) == $path)
			{
				return $fullname;
			}
		}

		// Could not find the file in the set of paths
		return false;
	}

	/**
	 * Resolves /./, /../ and multiple / in a string and returns the resulting absolute path, inspired by Flysystem
	 * Removes trailing slashes
	 *
	 * @param   string  $path  A path to resolve
	 *
	 * @return  string  The resolved path
	 *
	 * @since   1.6.0
	 */
	public static function resolve($path)
	{
		$path = static::clean($path);

		// Save start character for absolute path
		$startCharacter = ($path[0] === DIRECTORY_SEPARATOR) ? DIRECTORY_SEPARATOR : '';

		$parts = array();

		foreach (explode(DIRECTORY_SEPARATOR, $path) as $part)
		{
			switch ($part)
			{
				case '':
				case '.':
					break;

				case '..':
					if (empty($parts))
					{
						throw new FilesystemException('Path is outside of the defined root');
					}

					array_pop($parts);
					break;

				default:
					$parts[] = $part;
					break;
			}
		}

		return $startCharacter . implode(DIRECTORY_SEPARATOR, $parts);
	}

	/**
	 * Remove all references to root directory path and the system tmp path from a message
	 *
	 * @param   string  $message        The message to be cleaned
	 * @param   string  $rootDirectory  Optional root directory, defaults to JPATH_ROOT
	 *
	 * @return  string
	 * @since   2.0.1
	 */
	public static function removeRoot($message, $rootDirectory = null)
	{
		if (empty($rootDirectory))
		{
			$rootDirectory = JPATH_ROOT;
		}

		$makePattern = static function ($dir) {
			return '~' . str_replace('~', '\\~', preg_replace('~[/\\\\]+~', '.', $dir)) . '~';
		};

		$replacements = [
			$makePattern(static::clean($rootDirectory)) => '[ROOT]',
			$makePattern(sys_get_temp_dir())            => '[TMP]',
		];

		return preg_replace(array_keys($replacements), array_values($replacements), $message);
	}
}
