<?php namespace ExternalModules;

use \Exception;

require_once __DIR__ . '/../tests/phpcs-shared/SniffMessages.php';

const SCAN_TEST_FAILURE_OUTPUT_FILENAME = 'ScanTest_failure-output.txt';

class Scan
{
	/**
	 * This version should lag a little behind REDCap's System::minimum_php_version_required
	 * since it takes a while for most institutions to update, and some modules (e.g. Flight Tracker)
	 * want to keep supporting old REDCap versions in the meantime.
	 */
	const TARGET_PHP_VERSION = '8.0.2';
	const SKIP_LONG_RUNNING_CHECKS = '--skip-long-running-checks';
	const SKIP_CLEAN_REPO_CHECK = '--skip-clean-repo-check';
	const TAINTED_SSRF_LINE_PREFIX = '[0;31mWARNING[0m: TaintedSSRF';
	const PHP_VERSION_ERROR = 'The scan is not yet supported in PHP 8.4.';

	static $performLongRunningChecks = true;
	static $performCleanRepoCheck = true;

	private $composerConfig;
	private $composerInstallingMessageShown = false;

	static function isPHPVersionSupported(){
		return PHP_MAJOR_VERSION === 8 && PHP_MINOR_VERSION < 4;
	}

	static function run($outputFile, $path){
		if(!Scan::isPHPVersionSupported()){
			echo static::PHP_VERSION_ERROR;
			exit('1');
		}		

		$s = new Scan();

		if(version_compare(PHP_VERSION, '8.1', '<')){
			echo "\n";
			$s->showWarning('It is recommended to run this tool on PHP 8.1 or newer to find the most potential vulnerabilities, and avoid the most false positives.');
		}

		chdir(APP_PATH_EXTMOD);
		$s->downloadComposer();
		$s->runComposerInstall();

		echo "\n";

		chdir($path);

		$s->replaceStringsThatInterfereWithTheScan($path);

		$methods = [
			'checkComposerConfig',
			'checkForSystemHooks',
			'scanJavaScriptFiles',
		];

		if(static::$performLongRunningChecks){
			$methods = array_merge($methods, [
				'runPHPCS',
				'runPsalm',
				'runNpmAudit',
				'runComposerAudit',
			]);
		}

		$returnCode = 0;
		$output = '';
		foreach($methods as $methodName){
			ob_start();
			$errorsReturned = !empty($s->$methodName());
			$currentCommandOutput = trim(ob_get_contents());
			ob_end_clean();
			
			// file_put_contents(__DIR__ . "/$methodName", $currentCommandOutput);

			if($errorsReturned){
				$returnCode = 1;
				$output .= "$currentCommandOutput\n\n\n";
			}
		}

		if($returnCode === 0){
			$output = "The scan completed successfully, and no issues were detected!  This scan cannot guarantee security in all scenarios,\nand should not be considered a substitute for ensuring best practices are followed.";
		}
		else{
			$output .= "---------------------------------------------------------------------------------------------\n\n";
			$output .= "Please review the results above, consider any WARNINGs, and address any ERRORs.\nSolutions to ERRORs should also be applied in comparable scenarios throughout the codebase,\nas this scan is not capable of finding all potential vulnerabilities.\nIf you encounter false positives, or have any other difficulties running scans,\nplease reach out to redcap-external-module-framework@vumc.org or redcap.vumc.org/community/.";
		}

		if($outputFile){
			$output = htmlspecialchars($output, ENT_QUOTES);
			$output = ExternalModules::ansiToHtml($output);
			$output = "
				<p>
					This file was generated by the <b>scan</b> script bundled with REDCap.
					To learn more, go to
					<b>" . implode(' -> ', [
						'Control Center',
						'External Modules',
						'Manage',
						'Module Security Scanning',
					]) . "</b>.
				</p>
				<p>
					If you are receiving this in response to a REDCap Repo submission, a new submission is not required.
					Simply respond with a new or updated tag zip URL.
				</p>
				$output
			";

			file_put_contents($outputFile, $output);
		}
		else{
			echo "$output\n\n";
		}

		return $returnCode;
	}

	private function replaceStringsThatInterfereWithTheScan($rootPath){
		foreach(new \DirectoryIterator($rootPath) as $item){
			$path = $item->getPathname();
			if($item->isDot()){
				continue;
			}
			else if($item->isDir()){
				if(realpath($path) === realpath($this->getVendorPath())){
						// Skip the vendor dir, since we have psalm configured to ignore it anyway.
						continue;
				}
				$this->replaceStringsThatInterfereWithTheScan($path);
			}
			else if(strtolower($item->getExtension()) === 'php'){
				$content = file_get_contents($path);
				
				$replacementsMade = false;
				foreach([
					'@codingStandardsIgnore', // Covers multiple attributes with this prefix
					'phpcs:ignore', // Covers multiple attributes with this prefix
					'phpcs:disable',
				] as $s){
					$replacement = 'disabled-' . str_replace('@', '', $s);
					$content = str_replace($s, $replacement, $content, $count);

					if($count > 0){
						$replacementsMade = true;
					}
				}

				if($replacementsMade){
					file_put_contents($path, $content);
				}
			}
		}
	}

	private function getComposerConfig(){
		if(!isset($this->composerConfig)){
			$this->composerConfig = [];

			$composerPath = "composer.json";
			if(file_exists($composerPath)){
				$composerConfig = json_decode(file_get_contents($composerPath), true);
				
				/**
				 * Some modules don't have actual dependencies, but ONLY list a PHP version here
				 * and do not include a vendor dir with submissions.  This will prevent psalm 
				 * from running, because it will be confused as to why composer.json exists but
				 * not a vendor dir. We unset the php dependency to avoid this.
				 */
				unset($composerConfig['require']['php']);

				if(empty($composerConfig['require'])){
					/**
					 * Only 'require-dev' dependencies exist.  Remove composer.json to prevent psalm from getting confused & failing.
					 */
					unlink($composerPath);
				}
				else{
					$this->composerConfig = $composerConfig;
				}
			}
		}

		return $this->composerConfig;
	}

	private function getVendorPath(){
		return $this->getComposerConfig()['config']['vendor-dir'] ?? 'vendor/';
	}

	/**
	 * @psalm-suppress PossiblyUnusedMethod
	 */
	function setComposerConfig($config){
		$this->composerConfig = $config;
	}

	private function exit($message){
		if($message !== null){
			echo "$message\n";
		}
	
		exit(1);
	}

	private static function getEMVendorPath(){
		return APP_PATH_EXTMOD . "vendor";
	}

	static function getComposerPath(){
		return static::getEMVendorPath() . '/composer.phar'; 
	}

	function downloadComposer(){
		if(!is_writable(APP_PATH_EXTMOD)){
			$this->exit("This script requires that the current user have write access the following path: " . APP_PATH_EXTMOD);
		}

		$vendorPath = static::getEMVendorPath();
		$composerPath = static::getComposerPath();
		$downloadComposer = true;
		if(file_exists($composerPath)){
			$oneYear = 60*60*24*365;
			$ageOfComposer = time() - filemtime($composerPath);
			if($ageOfComposer < $oneYear){
				$downloadComposer = false;
			}
		}

		if($downloadComposer){
			if(!file_exists($vendorPath)){
				mkdir($vendorPath);
			}

			$composerUrl = 'https://getcomposer.org/download/latest-stable/composer.phar';
			if(!copy($composerUrl, $composerPath)){
				$this->exit("Failed to download composer from '$composerUrl'.  If a firewall is preventing the download, please manually download that file to '$composerPath'.");
			};
		}
	}

	function runComposerInstall(){
		$composerPath = static::getComposerPath();

		$process = proc_open(
			"php $composerPath install -q", [
				0 => ['pipe', 'r'],
				1 => ['pipe', 'w'],
				2 => ['pipe', 'w'],
			],
			$pipes
		);

		$startTime = time();
		while(true){
			usleep(100000);

			$secondsElapsed = time() - $startTime;
			if($secondsElapsed >= 5 && !$this->composerInstallingMessageShown){
				echo "Running composer install in " . APP_PATH_EXTMOD . "\n";
				$this->composerInstallingMessageShown = true;
			}

			$status = proc_get_status($process);
			if(!$status['running']){
				break;
			}
		}

		if($status['exitcode'] !== 0){
			$output = stream_get_contents($pipes[1]);
			$error = stream_get_contents($pipes[2]);
			$this->exit("Composer install in '" . getcwd() . "' failed with the following output:\n$output\n\n$error");
		}

		proc_close($process);
	}

	private function isModule(){
		return SniffMessages::isModule(getcwd());
	}

	private function getConfig(){
		return SniffMessages::getConfig(getcwd());
	}

	/**
	 * @psalm-suppress PossiblyUnusedMethod
	 */
	function setConfig($config){
		return SniffMessages::setConfig(getcwd(), $config);
	}

	/**
	 * @psalm-suppress PossiblyUnusedMethod
	 */
	function checkComposerConfig(){
		$composerConfig = $this->getComposerConfig();
		if($composerConfig === []){
			// Composer is not used, so nothing to check
			return;
		}

		$composerPHPVersion = $composerConfig['config']['platform']['php'] ?? '';
		$moduleMin = $this->getConfig()['compatibility']['php-version-min'] ?? '';
		$target = static::TARGET_PHP_VERSION;
		$errorShown = false;
		if($composerPHPVersion === ''){
			if(version_compare($target, $moduleMin, '>')){
				$recommendedVersion = $target;
				$recommendedVersionSource = null;
			}
			else{
				$recommendedVersion = $moduleMin;
				$recommendedVersionSource = 'your module';
			}

			$this->showError($this->getComposerVersionMissingMessage($recommendedVersion, $recommendedVersionSource));
			$errorShown = true;
		}
		else if(
			version_compare($composerPHPVersion, $target, '>')
			&&
			version_compare($composerPHPVersion, $moduleMin, '>')
		){
			$this->showError($this->getComposerHigherThanModulePHPVersionMessage());
			$errorShown = true;
		}

		return $errorShown;
	}

	function getComposerVersionMissingMessage($recommendedVersion, $recommendedVersionSource){
		$valueString = $recommendedVersion;
		if($recommendedVersionSource !== null){
			$valueString .= " ($recommendedVersionSource's minimum required version)";
		}

		return $this->formatMessage("
			A platform PHP version should be set in composer.json to ensure dependencies are compatible with the desired PHP versions.
			A value of $valueString is recommended, and can be added to composer.json via the following commands:

				composer config platform.php $recommendedVersion
				composer update
		");
	}

	function getComposerHigherThanModulePHPVersionMessage(){
		return $this->formatMessage("
			The platform PHP version in composer.json should be lesser than or equal to php-version-min in config.json.
			This ensures that the module cannot be enabled on any systems where the dependencies are not supported.
		");
	}

	/**
	 * @psalm-suppress UnusedMethod
	 */
	private function checkForSystemHooks(){
		if(!$this->isModule()){
			return;
		}

		$warningDisplayed = false;
		$config = $this->getConfig();

		$showWarning = function($message) use (&$warningDisplayed){
			$warningDisplayed = true;
			$this->showWarning($message);
		};

		if($config['enable-every-page-hooks-on-system-pages'] ?? false === true){
			$showWarning("
				The 'enable-every-page-hooks-on-system-pages' flag is set to 'true' in config.json.
				If this is not intentional, please remove this flag from config.json.
				If this flag is required, please review any changes since the last scan
				that could influence the behavior of any hooks beginning with 'redcap_every_page_'.
				Please ensure that any such hooks return immediately if the expected page/context is not detected.
			");
		}

		if($config['enable-email-hook-in-system-contexts'] ?? false === true){
			$showWarning("
				The 'enable-email-hook-in-system-contexts' flag is set to 'true' in config.json.
				If this is not intentional, please remove this flag from config.json.
				If this flag is required, please review any changes since the last scan
				that could influence the behavior of the 'redcap_email' hook.
				Please ensure that it can appropriately handle any & all emails sent by REDCap
				or any other external modules.
			");
		}
		if(
			($config['enable-no-auth-logging'] ?? false === true)
			&&
			!in_array($config['namespace'], [
				/**
				 * This module uses no auth logging extensively, and is difficult to definitively scan.
				 * It is well written and generally trusted though.
				 */
				"YaleREDCap\\REDCapPRO"
			])
		){
			$showWarning("
				The 'enable-no-auth-logging' flag is set to 'true' in config.json.
				If logging is not required for unauthenticated users, please remove this flag from config.json.
				If this flag is required, please review changes since the last scan that could influence unauthenticated log behavior.
				To minimize risk of exploitation, please use hard coded strings or allow lists for logged variables wherever possible.
				If any logged values must be sourced from request variables, please ensure that a malicious actor cannot use those values 
				to compromise security or adversely influence module behavior in any way.
				Please review both PHP and JavaScript log() calls.
			");
		}

		if($config['no-auth-ajax-actions'] ?? false === true){
			$showWarning("
				The 'no-auth-ajax-actions' flag is set to 'true' in config.json.
				If the JavaScript module.ajax() method is not required for unauthenticated users, please remove this flag from config.json.
				If this flag is required, please review changes since the last scan that could influence unauthenticated ajax() call behavior.
				To minimize risk of exploitation, please use hard coded strings or allow lists for the ajax data/payload wherever possible.
				If any portion of the data/payload must be sourced from request variables, please ensure that a malicious actor cannot use that data 
				to compromise security or adversely influence module behavior in any way.
			");
		}

		return $warningDisplayed;
	}

	/**
	 * Psalm already does a good job of detecting risky eval() calls in PHP.
	 * This method detects them in JS files.
	 * 
	 * We originally used PHPCS to scan JS files, but it ran out of memory trying to tokenize large JS files, like in the following module:
	 * https://github.com/dr01d3r/redcap-em-biospecimen-tracking/releases/download/v0.9.2-beta/biospecimen_tracking_v0.9.2.zip
	 * 
	 * We also considered using PHPCS's eslint support, but that would require
	 * a Node & Rhino dependency which is not worth the hassle for now.
	 * 
	 * @psalm-suppress UnusedMethod
	 */
	private function scanJavaScriptFiles(){
		$errorsExist = false;
		foreach (
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator(getcwd(), \RecursiveDirectoryIterator::SKIP_DOTS),
				\RecursiveIteratorIterator::SELF_FIRST
			) as $unused
		) {
			$path = $iterator->getSubPathname();
			if(
				str_ends_with($path, '.js')
				&&
				is_file($path)
				&&
				!str_starts_with($path, 'node_modules')
				&&
				!str_contains($path, 'ckeditor/samples')
				&&
				!str_contains(strtolower(basename($path)), 'imagemapster')
			){
				$handle = fopen($path, "r");
				if(!$handle){
					throw new \Exception('Error opening file: ' . $path);
				}

				$getErrorLine = function($path, $lineNumber, $message){
					return "$path:$lineNumber - $message\n";
				};

				// stdClass is used here instead of a primitive to avoid a psalm warning
				$info = new \stdClass;
				$info->lineNumber = 1;
				$showError = function($message) use ($path, $info, $getErrorLine, &$errorsExist){
					echo $getErrorLine($path, $info->lineNumber, $message);
					$errorsExist = true;
				};

				$evalSourceMap = false;
				while (($line = fgets($handle)) !== false) {
					// Use str_starts_with() because there might be trailing spaces
					if(str_starts_with($line, ' * ATTENTION: An "eval-source-map" devtool has been used.')){
						$evalSourceMap = true;
						continue;
					}

					if($evalSourceMap){
						/**
						 * False positive edge case in:
						 * https://github.com/Research-IT-Swiss-TPH/redcap-record-home-dashboard/archive/refs/tags/v2.4.0.zip
						 */
						$prefix = 'eval("';
						if(str_starts_with($line, $prefix)){
							$line = substr($line, strlen($prefix), -4);
						}
					}
					
					if(SniffMessages::doesLineContainJSEval($line)){
						$showError(SniffMessages::JS_EVAL);
					}

					$info->lineNumber++;
				}

				fclose($handle);				
			}
		}

		return $errorsExist;
	}

	private function showWarning($message){
		$this->showMessage('WARNING', $message);
	}

	private function showError($message){
		$this->showMessage('ERROR', $message);
	}

	private function showMessage($type, $message){
		echo "[0;31m$type[0m: " . $this->formatMessage($message);
	}

	static function formatMessage($message){
		return SniffMessages::formatMessage($message);
	}

	private function getCommandSeparatorLines($label){	
		return implode("\n", [
			"------------------------------------------------------------",
			"Running $label",
			"",
		]);
	}

	/**
	 * @psalm-suppress UnusedMethod
	 */
	private function runPsalm(){
		global $argv;

		putenv('PSALM_ALLOW_XDEBUG=1');

		$psalmArgs = ' --php-version='. ExternalModules::limitVersion(static::TARGET_PHP_VERSION, 2);
		if (in_array('--debug', $argv)) {
			$psalmArgs .= ' --debug';
		}

		$tempPath = getcwd();
		$cachePath = "$tempPath/psalm-cache";
		mkdir($cachePath);

		static::createPsalmConfig($tempPath, basename($cachePath));

		$psalmPath = $this->getPsalmPath();

		$runCommand = function($command, $label){
			$output = $this->getCommandSeparatorLines($label);
			$lines = [];

			exec($command, $lines, $result);
			if(
				/**
				 * Match both of these prefixes:
				 * 		Psalm was able to infer types for
				 * 		Psalm was unable to infer types
				 */
				!str_contains(end($lines), 'able to infer types')
			){
				echo implode("\n", $lines);
				throw new \Exception('Psalm did not finish executing.  This could be caused by a fatal error or die/exit call in one of our Psalm plugins.');
			}

			$appendRecommendedSolution = function($line){
				$errorLinePrefix = "[0;31mERROR[0m:";
				$getSolution = function($line) use ($errorLinePrefix){
					$htmlSolution = implode("\n", [
						'$module->escape()[0m.',
						'Building HTML via Twig templates would also resolve this and future taints, as they escape',
						'all inputs by default (see the [30;47m$module->getTwig()[0m method docs for details).',
						'If the user input itself is HTML, sanitizing it via the [30;47mfilter_tags()[0m method may be sufficient[30;47m'
					]);

					$solutionsByType = [
						'TaintedHtml' => $htmlSolution,
						'TaintedShell' => 'escapeshellcmd($taintedString)[0m or [30;47mescapeshellarg($taintedString)',
						'TaintedTextWithQuotes' => $htmlSolution,
					];

					$isTaint = function($type) use ($line, $errorLinePrefix){
						return str_starts_with($line, "$errorLinePrefix $type");
					};

					if($isTaint('TaintedSql')){
						return [
							'To resolve this, use the [30;47m$module->query()[0m method and pass the variable highlighted at the last step of the trace',
							'as a parameter via the second argument to the [30;47mquery()[0m method.  See REDCap\'s developer documentation for details.'
						];
					}
					else if($isTaint('TaintedFile') || $isTaint('TaintedInclude')){
						return [
							'To resolve this, use the [30;47m$module->getSafePath()[0m method to obtain a path that is guaranteed',
							'to be within the expected parent directory. See REDCap\'s developer documentation for details.'
						];
					}

					foreach($solutionsByType as $type=>$solution){
						if($isTaint($type)){
							return [
								"This is generally resolved by finding the simplest highlighted variable in the trace below (ideally a short string)",
								"and wrapping it in [30;47m$solution[0m.",
							];
						}
					}

					return null;
				};

				$lines = [$line];
				
				if(str_starts_with($line, $errorLinePrefix)){
					$solution = $getSolution($line);
					if($solution !== null){
						array_push($lines, ...$solution);
					}

					array_push($lines, "");
				}

				array_push($lines, "");

				return $lines;
			};

			foreach($lines as $line){
				if(str_starts_with($line, '[0;31mERROR[0m: TaintedSSRF')){
					$line = str_replace('[0;31mERROR[0m: TaintedSSRF', static::TAINTED_SSRF_LINE_PREFIX, $line);
					$line .= implode("\n", [
						'',
						'Please keep in mind that passing data to third parties may constitute a HIPAA violation.',
						'Passing seemingly innocent public data (like an address) could in aggregate reveal community demographics.',
						'Even a referer URL (e.g. redcap.cancercenter.edu) associated with certain data (like an address) can unintentionally disclose personal information.',
						'',
					]);
				}

				$output .= implode("\n", $appendRecommendedSolution($line));
			}

			echo trim($output);

			return $result;
		};

		/**
		 * Running psalm normally works, but it finds a bunch of things that we don't
		 * currently expect module authors to solve (including issues in REDCap core).
		 * We will likely leave this commented indefinitely.
		 */
		// $runCommand("$psalmPath $psalmArgs", "Psalm");

		$returnCode = $runCommand("$psalmPath $psalmArgs --taint-analysis --no-progress", "Psalm's Taint Analysis");

		return $returnCode;
	}

	static function verifyCleanGitDirs(){
		(new Scan())->getPsalmPath();
	}

	private function getPsalmPath(){
		if($this->shouldUsePsalmDevVersion()){
			$psalmDirPath = '~/downloads/psalm';

			$this->verifyCleanGitRepo(exec("echo $psalmDirPath"), 'fix-core-function-flow');
			$this->verifyCleanGitRepo(APP_PATH_DOCROOT, REDCAP_VERSION);

			if(static::$performCleanRepoCheck){
				$this->verifyCleanGitRepo(APP_PATH_EXTMOD, 'testing');
			}

			$psalmPath = "php $psalmDirPath/psalm";
		}
		else{
			$vendorPath = static::getEMVendorPath();
	
			$psalmPath = "$vendorPath/bin/psalm";
			if(PHP_OS_FAMILY === "Windows"){
				$psalmPath .= '.bat';
			}
		}

		return $psalmPath;
	}

	private function verifyCleanGitRepo($dir, $minimumCommit){
		$originalDir = getcwd();
		chdir($dir);

		exec("git log ..$minimumCommit", $lines);
		if(!empty($lines)){
			exit("Mark, please check out the '$minimumCommit' branch/tag, or make sure the current branch is up to date with it for the following repo: $dir\n");
		}
		
		exec("git status", $lines);

		$section = null;
		foreach($lines as $line){
			if(str_starts_with($line, "\t")){
				// This section has files
				if($section !== 'Changes to be committed:'){
					if(str_ends_with($line, SCAN_TEST_FAILURE_OUTPUT_FILENAME)){
						continue;
					}

					// This must be the "Changes not staged for commit" or "Untracked files" section
					echo "\n";
					$this->showWarning("Mark, all outstanding changes in the following repo must be staged in order to run a scan: $dir");
					break;
				}
			}
			else if(!str_starts_with($line, ' ')){
				$section = $line;
			}
		}

		chdir($originalDir);
	}

	private function shouldUsePsalmDevVersion(){
		/**
		 * This was used back when Mark was more actively developing changes to Psalm.
		 * Let's not worry about it for now since there are multiple framework developers now.
		 */
		return false;

		// if(!file_exists('/etc/wsl.conf')){
		// 	return false;
		// }

		// exec("cmd.exe /c echo %USERNAME% 2>/dev/null", $usernameLines);
		// return $usernameLines[0] === 'mceverm';
	}

	private function createPsalmConfig($dirPath, $cacheDir){
		$attributes = '';
		$addAttribute = function($name, $value) use (&$attributes){
			$attributes .= "\n\t$name=\"$value\"";
		};

		$addAttribute('errorLevel', 8);
		$addAttribute('findUnusedBaselineEntry', 'false');
		$addAttribute('findUnusedCode', 'false');

		if($cacheDir !== null){
			$addAttribute('cacheDirectory', $cacheDir);
		}

		$emPath = $this->getRelativePath($dirPath, APP_PATH_EXTMOD);
		$addAttribute('autoloader', $emPath . '/psalm/autoload.php');

		$moduleClassRow = '';
		if($this->isModule()){
			$config = $this->getConfig();
			$parts = explode('\\', $config['namespace']);
			$parts[] = end($parts);
			$moduleClass = implode('\\', $parts);
			$moduleClassRow = '<var name="module" type="' . $moduleClass . '" />';
		}

		$sharedConfigSections = static::getSharedPsalmConfigSections();

		$psalmConfigXml = '<?xml version="1.0"?>
<psalm ' . $attributes . '
>
	<projectFiles>
		<directory name="." />
		<ignoreFiles allowMissingFiles="true">
			<!-- Ignore the third party dependencies, because they can cause the scan to take a long time or hang on some modules -->
			<directory name="' . $this->getVendorPath() . '" />
			<directory name="node_modules" />
		</ignoreFiles>
	</projectFiles>
	<plugins>
		<plugin filename="' . __DIR__ . '/../psalm/REDCapPsalmPlugin.php' . '" />
	</plugins>
	<stubs>
		' . implode("\n\t\t", $sharedConfigSections['stubs']) . '
	</stubs>
	<globals>
		' . implode("\n\t\t", $sharedConfigSections['globals']) . '
		' . $moduleClassRow . '
	</globals>
</psalm>
';

		file_put_contents("$dirPath/psalm.xml", $psalmConfigXml);
	}

	private function getSharedPsalmConfigSections(){
		$config = simplexml_load_file(__DIR__ . '/../psalm.xml');

		$stubs = [];
		$globals = [];

		foreach($config->stubs->file as $file){
			$path = APP_PATH_EXTMOD . $file->attributes()['name']->__toString();
			$stubs[] = '<file name="' . $path . '" />';
		}

		$addGlobals = false;
		foreach($config->globals->var as $var){
			$name = $var->attributes()['name']->__toString();
			$type = $var->attributes()['type']->__toString();

			if($name === 'rc_connection'){
				// We're in the section of shared vars
				$addGlobals = true;
			}

			if($addGlobals){
				$globals[] = '<var name="' . $name . '" type="' . $type . '" />';
			}
		}

		return [
			'stubs' => $stubs,
			'globals' => $globals,
		];
	}

	/**
	 * Used for psalm, which doesn't accept absolute paths.
	 */
	private function getRelativePath($fromPath, $toPath){
		// Normalize any extraneous slashes
		$fromPath = realpath($fromPath);
		$toPath = realpath($toPath);

		$parts = explode(DIRECTORY_SEPARATOR, $fromPath);

		$result = "";
		array_pop($parts); // Don't account for leading slash
		foreach($parts as $part){
			$result .= "../";
		}

		$suffix = $toPath;
		if(PHP_OS_FAMILY === "Windows"){
			$suffix = explode(":", $suffix)[1];
		}

		return $result . $suffix;
	}

	/**
	 * @psalm-suppress UnusedMethod
	 */
	private function runPHPCS(){
		echo $this->getCommandSeparatorLines('coding standard checks via phpcs');

		file_put_contents('ruleset.xml', '<?xml version="1.0"?>
			<ruleset>
				<rule ref="Internal.NoCodeFound">
					<severity>0</severity>
				</rule>
			</ruleset>
		');

		$minPhpVersion = $this->getConfig()['compatibility']['php-version-min'] ?? '';
		$redcapMinPhpVersion = static::TARGET_PHP_VERSION;
		if(version_compare($redcapMinPhpVersion, $minPhpVersion, '>')){
			$minPhpVersion = $redcapMinPhpVersion;
		}
		$minPhpVersion = ExternalModules::limitVersion($minPhpVersion, 2);

		$maxPhpVersion = $this->getConfig()['compatibility']['php-version-max'] ?? '';
		$maxPhpVersion = ExternalModules::limitVersion($maxPhpVersion, 2);
		if(version_compare($minPhpVersion, $maxPhpVersion, '>')){
			$maxPhpVersion = '';
		}

		$excludes = [
			'PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue',
			'PHPCompatibility.Variables.NewUniformVariableSyntax',
			'PHPCompatibility.Extensions.RemovedExtensions',
			'PHPCompatibility.FunctionUse.NewFunctions', // Replaced by ExternalModules\Sniffs\Misc\NewFunctionsSniff
		];

		if(!$this->isModule()){
			/**
			 * We currently only require namespaces for modules (not plugins or hooks).
			 */
			$excludes[] = 'ExternalModules.Misc.RequireNamespace';
		}

		$emRoot = __DIR__ . '/..';
		$vendorPath = $this->getVendorPath();
		system(implode(' ', [
			"$emRoot/vendor/bin/phpcs --report-width=100 -d memory_limit=2G -s --runtime-set testVersion $minPhpVersion-$maxPhpVersion --runtime-set installed_paths $emRoot/vendor/phpcompatibility/php-compatibility/PHPCompatibility --extensions=php,vue --ignore=/{$vendorPath} .",
			"--standard=ruleset.xml,$emRoot/tests/phpcs-scan,$emRoot/tests/phpcs-shared,$emRoot/vendor/phpcompatibility/php-compatibility/PHPCompatibility",
			"--exclude=" . implode(',', $excludes),
		]), $resultCode);

		return $resultCode;
	}

	static function isManuallyUploadedGitHubAssetUrl($url){
		$url = str_replace('archive/refs/tags', 'archive', $url);
		$parts = explode('/', $url);

		if($parts[2] === 'github.com'){
			if(
				$parts[5] === 'archive'
			){
				return false;
			}
			else if(
				$parts[5] === 'releases' &&
				$parts[6] === 'download'
			){
				return true;
			}
		}

		throw new \Exception('Unrecognized GitHub URL format!');
	}

	static function verifyManuallyUploadedGitHubAsset($uploadedUrl, $uploadedPath, $exitAction){
		$parts = explode('/', $uploadedUrl);
		$tag = $parts[7];
		array_splice($parts, 5, null, ['archive', 'refs', 'tags', "$tag.zip"]);
		$basicTagUrl = implode('/', $parts);

		$basicTagPath = ExternalModules::createTempDir();
		ExternalModules::downloadModuleZip($basicTagUrl, $basicTagPath, $exitAction);
		
		$errors = [];
		$compareDirs = function($dir1, $dir2) use (&$errors, &$compareDirs){
			$areFilesIdentical = function($path1, $path2){
				$getContent = function($path){
					$content = file_get_contents($path);

					/**
					 * On Orca Specimen Tracking v1.0.3 new lines differ between the tag & the zip.
					 */
					$content = str_replace("\r", "", $content);

					return $content;
				};

				$content1 = $getContent($path1);
				$content2 = $getContent($path2);

				return $content1 === $content2;
			};

			foreach(new \DirectoryIterator($dir1) as $item){
				$path = substr($item->getPathname(), strlen($dir1)+1);
				$fullPath2 = "$dir2/$path";
				if(
					$item->isDot()
					||
					/**
					 * It's ok for some files to exist only in the tag, and be excluded from release ZIPs
					 * (e.g. https://github.com/dr01d3r/redcap-em-orca-specimen-tracking/releases/download/v2.0.0/orca_specimen_tracking_v2.0.0.zip).
					 */
					!file_exists($fullPath2) 
				){
					continue;
				}
				else if(!file_exists($fullPath2)){
					$errors[] = "Only in tag: $path";
				}
				else if($item->isDir() && is_dir($fullPath2)){
					$compareDirs($item->getPathname(), $fullPath2);
				}
				else if(
					($item->isDir() && !is_dir($fullPath2))
					||
					(!$item->isDir() && is_dir($fullPath2))
					||
					!$areFilesIdentical($item->getPathname(), $fullPath2)
				){
					$errors[] = "Files differ: $path";
				}
			}
		};
		
		// Check in this order to avoid things created by build processes, like the vendor dir, node_modules, Eclipse build files, etc.
		$compareDirs($basicTagPath, $uploadedPath);

		if(!empty($errors)){
			$exitAction("The specified zip has unexpected differences from the tag:\n\t" . implode("\n\t", $errors) . "\n");
		}
	}

	/**
	 * @psalm-suppress UnusedMethod
	 */
	function runNpmAudit(){
		if(!file_exists('node_modules')){
			return 0;
		}

		/**
		 * The package files may not exist (if excluded from the release zip via .gitattributes),
		 * or they may not match the packages actually present.
		 * Regardless, trust the content of node_modules instead.
		 */
		foreach(['package.json', 'package-lock.json'] as $path){
			copy('node_modules/.package-lock.json', $path);
		}

		$auditCommand = 'npm audit';
		if(static::runCommand('npm list')){
			// Assume dev dependencies are not installed, but prod dependencies are.
			$auditCommand .= ' --omit=dev';
		}

		return static::runCommand($auditCommand);
	}

	/**
	 * @psalm-suppress UnusedMethod
	 */
	function runComposerAudit(){
		if(!file_exists('vendor')){
			return 0;
		}

		/**
		 * The composer files may not exist (if excluded from the release zip via .gitattributes),
		 * or they may not match the dependencies actually present.
		 * Regardless, clear these files, to ensure that composer to uses the vendor dir's contents instead.
		 */
		file_put_contents('composer.json', '{}');
		ExternalModules::rrmdir('composer-lock.json');

		return static::runCommand('php ' . static::getComposerPath() . ' audit');
	}

	private static function runCommand($c){
		system("$c 2>&1", $result);
		return $result;
	}
}
