1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Salient\Core\Facade\Err;
6: use LogicException;
7: use RuntimeException;
8:
9: /**
10: * Work with the runtime environment
11: *
12: * @api
13: */
14: final class Sys extends AbstractUtility
15: {
16: /**
17: * Get the configured memory_limit, in bytes
18: */
19: public static function getMemoryLimit(): int
20: {
21: return Get::bytes((string) ini_get('memory_limit'));
22: }
23:
24: /**
25: * Get the current memory usage of the script as a percentage of the
26: * configured memory_limit
27: */
28: public static function getMemoryUsagePercent(): float
29: {
30: $limit = self::getMemoryLimit();
31:
32: return $limit <= 0
33: ? 0
34: : (memory_get_usage() * 100 / $limit);
35: }
36:
37: /**
38: * Get user and system CPU times for the current run, in microseconds
39: *
40: * @return array{int,int} `[ <user_time>, <system_time> ]`
41: */
42: public static function getCpuUsage(): array
43: {
44: $usage = getrusage();
45:
46: if ($usage === false) {
47: // @codeCoverageIgnoreStart
48: return [0, 0];
49: // @codeCoverageIgnoreEnd
50: }
51:
52: $user_s = $usage['ru_utime.tv_sec'] ?? 0;
53: $user_us = $usage['ru_utime.tv_usec'] ?? 0;
54: $sys_s = $usage['ru_stime.tv_sec'] ?? 0;
55: $sys_us = $usage['ru_stime.tv_usec'] ?? 0;
56:
57: return [
58: $user_s * 1000000 + $user_us,
59: $sys_s * 1000000 + $sys_us,
60: ];
61: }
62:
63: /**
64: * Get the filename used to run the script
65: *
66: * Use `$parentDir` to get the running script's path relative to a parent
67: * directory.
68: *
69: * @throws LogicException if the running script is not in `$parentDir`.
70: */
71: public static function getProgramName(?string $parentDir = null): string
72: {
73: /** @var string */
74: $filename = $_SERVER['SCRIPT_FILENAME'];
75:
76: if ($parentDir === null) {
77: return $filename;
78: }
79:
80: $relative = File::getRelativePath($filename, $parentDir);
81: if ($relative === null) {
82: throw new LogicException(sprintf(
83: "'%s' is not in '%s'",
84: $filename,
85: $parentDir,
86: ));
87: }
88:
89: return $relative;
90: }
91:
92: /**
93: * Get the basename of the file used to run the script
94: *
95: * @param string ...$suffix Removed from the end of the filename.
96: */
97: public static function getProgramBasename(string ...$suffix): string
98: {
99: /** @var string */
100: $filename = $_SERVER['SCRIPT_FILENAME'];
101: $basename = basename($filename);
102:
103: if (!$suffix) {
104: return $basename;
105: }
106:
107: foreach ($suffix as $suffix) {
108: if ($suffix === $basename) {
109: continue;
110: }
111: $length = strlen($suffix);
112: if (substr($basename, -$length) === $suffix) {
113: return substr($basename, 0, -$length);
114: }
115: }
116:
117: return $basename;
118: }
119:
120: /**
121: * Get the directory PHP uses for temporary file storage by default
122: *
123: * @throws RuntimeException if the path returned by
124: * {@see sys_get_temp_dir()} is not a writable directory.
125: */
126: public static function getTempDir(): string
127: {
128: $tempDir = sys_get_temp_dir();
129: $dir = @realpath($tempDir);
130: if ($dir === false || !is_dir($dir) || !is_writable($dir)) {
131: // @codeCoverageIgnoreStart
132: throw new RuntimeException(
133: sprintf('Not a writable directory: %s', $tempDir),
134: );
135: // @codeCoverageIgnoreEnd
136: }
137: return $dir;
138: }
139:
140: /**
141: * Get the user ID or username of the current user
142: *
143: * @return int|string
144: */
145: public static function getUserId()
146: {
147: if (function_exists('posix_geteuid')) {
148: return posix_geteuid();
149: }
150:
151: $user = Env::getNullable(
152: 'USERNAME',
153: fn() => Env::getNullable('USER', null),
154: );
155: if ($user === null) {
156: // @codeCoverageIgnoreStart
157: throw new RuntimeException('Unable to identify user');
158: // @codeCoverageIgnoreEnd
159: }
160: return $user;
161: }
162:
163: /**
164: * Check if the script is running as the root user
165: */
166: public static function isRunningAsRoot(): bool
167: {
168: return function_exists('posix_geteuid')
169: && posix_geteuid() === 0;
170: }
171:
172: /**
173: * Check if there is a running process with the given process ID
174: */
175: public static function isProcessRunning(int $pid): bool
176: {
177: if (!self::isWindows()) {
178: return posix_kill($pid, 0);
179: }
180:
181: $command = sprintf('tasklist /fo csv /nh /fi "PID eq %d"', $pid);
182: $stream = File::openPipe($command, 'r');
183: $csv = File::getCsv($stream);
184: if (File::closePipe($stream, $command) !== 0) {
185: // @codeCoverageIgnoreStart
186: throw new RuntimeException(
187: sprintf('Command failed: %s', $command)
188: );
189: // @codeCoverageIgnoreEnd
190: }
191:
192: return count($csv) === 1
193: && isset($csv[0][1])
194: && $csv[0][1] === (string) $pid;
195: }
196:
197: /**
198: * Escape and implode a command line for use in a shell command
199: *
200: * This method should not be used to prepare commands for
201: * {@see proc_open()}. It is intended for use with {@see popen()} and
202: * {@see File::openPipe()}.
203: *
204: * @param non-empty-array<string> $args
205: */
206: public static function escapeCommand(array $args): string
207: {
208: $windows = self::isWindows();
209:
210: foreach ($args as $arg) {
211: $escaped[] = $windows
212: ? self::escapeCmdArg($arg)
213: : self::escapeShellArg($arg);
214: }
215:
216: return implode(' ', $escaped);
217: }
218:
219: /**
220: * Escape an argument for POSIX-compatible shells
221: */
222: private static function escapeShellArg(string $arg): string
223: {
224: return $arg === ''
225: || Regex::match('/[^a-z0-9+.\/@_-]/i', $arg)
226: ? "'" . str_replace("'", "'\''", $arg) . "'"
227: : $arg;
228: }
229:
230: /**
231: * Escape an argument for cmd.exe on Windows
232: *
233: * Derived from `Composer\Util\ProcessExecutor::escapeArgument()`, which
234: * credits <https://github.com/johnstevenson/winbox-args>.
235: */
236: private static function escapeCmdArg(string $arg): string
237: {
238: $arg = Regex::replace('/(\\\\*)"/', '$1$1\"', $arg, -1, $quoteCount);
239: $quote = $arg === '' || strpbrk($arg, " \t,") !== false;
240: $meta = $quoteCount > 0 || Regex::match('/%[^%]+%|![^!]+!/', $arg);
241:
242: if (!$meta && !$quote) {
243: $quote = strpbrk($arg, '^&|<>()') !== false;
244: }
245:
246: if ($quote) {
247: $arg = '"' . Regex::replace('/(\\\\*)$/', '$1$1', $arg) . '"';
248: }
249:
250: if ($meta) {
251: $arg = Regex::replace('/["^&|<>()%!]/', '^$0', $arg);
252: }
253:
254: return $arg;
255: }
256:
257: /**
258: * Check if the script is running on Windows
259: */
260: public static function isWindows(): bool
261: {
262: return \PHP_OS_FAMILY === 'Windows';
263: }
264:
265: /**
266: * Make a clean exit from the running script on SIGTERM, SIGINT or SIGHUP
267: *
268: * @return bool `false` if signal handlers can't be installed on this
269: * platform, otherwise `true`.
270: */
271: public static function handleExitSignals(): bool
272: {
273: if (!function_exists('pcntl_async_signals')) {
274: return false;
275: }
276:
277: $handler = static function (int $signal): void {
278: // @codeCoverageIgnoreStart
279: $status = 128 + $signal;
280: if (
281: class_exists(Err::class)
282: && Err::isLoaded()
283: && Err::isRegistered()
284: ) {
285: Err::handleExitSignal($status);
286: }
287: exit($status);
288: // @codeCoverageIgnoreEnd
289: };
290:
291: pcntl_async_signals(true);
292:
293: return pcntl_signal(\SIGTERM, $handler)
294: && pcntl_signal(\SIGINT, $handler)
295: && pcntl_signal(\SIGHUP, $handler);
296: }
297: }
298: