1: <?php declare(strict_types=1);
2: /**
3: * +------------------------------------------------------------+
4: * | apnscp |
5: * +------------------------------------------------------------+
6: * | Copyright (c) Apis Networks |
7: * +------------------------------------------------------------+
8: * | Licensed under Artistic License 2.0 |
9: * +------------------------------------------------------------+
10: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
11: * +------------------------------------------------------------+
12: */
13:
14: /**
15: * Provides file interaction and ACL support
16: *
17: * @package core
18: *
19: * @todo add xattr support
20: */
21: class File_Module extends Module_Skeleton
22: {
23: const DEPENDENCY_MAP = [
24: 'siteinfo',
25: 'diskquota'
26: ];
27: const UPLOAD_UID = WS_UID;
28: const STCACHE_ROOT = '6666cd76f96956469e7be39d750cc7d9'; // md5("/")
29: const ACL_MODE_RECURSIVE = 'R';
30: const ACL_MODE_DEFAULT = 'd';
31: const ACL_NO_RECALC_MASK = 'n';
32: const ACL_FLAGS = '-PRbdkxn';
33: // under apnscp root
34: const DOWNLOAD_SKIP_LIST = '/config/file_download_skiplist.txt';
35: // values used in computing referent permissions
36: const LINK_KEYMAP = [
37: 'gid' => 1,
38: 'uid' => 1,
39: 'mode' => 1,
40: 'ino' => 1
41: ];
42:
43: private static $registered_extensions = array(
44: 'zip' => 'zip',
45: 'tgz' => 'gzip',
46: 'tar' => 'tar',
47: 'tar.gz' => 'gzip',
48: 'gz' => 'gzip',
49: 'bz' => 'bzip',
50: 'bz2' => 'bzip',
51: 'tar.bz' => 'bzip',
52: 'tar.bz2' => 'bzip',
53: 'tbz' => 'bzip',
54: 'tbz2' => 'bzip'
55: );
56: private $stat_cache = [];
57: // assume all operations exist on shadow/
58: // if user is admin, bypass costly stat checks
59: private $acl_cache = [];
60: private $uid_translation = [];
61: private $compression_instances;
62:
63: // apply settings recursively
64: private $trans_paths = array();
65: // apply settings as default
66: private $cached;
67: // don't recalculate effective rights mask
68: private $clearstat = false;
69: // all valid ACL flags
70: private $_optimizedShadowAssertion = 1;
71:
72: /**
73: * {{{ void __construct(void)
74: *
75: * @ignore
76: */
77: public function __construct()
78: {
79: parent::__construct();
80:
81: foreach (array_unique(array_values(self::$registered_extensions)) as $iface) {
82: $this->compression_instances[$iface] = null;
83: }
84: if ($this->_optimizedShadowAssertion && version_compare(platform_version(), '6', '>=')) {
85: $this->_optimizedShadowAssertion = 2;
86: }
87:
88: $this->exportedFunctions = array(
89: '*' => PRIVILEGE_ALL,
90: 'canonicalize_site' => PRIVILEGE_SITE | PRIVILEGE_USER,
91: 'change_file_permissions_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
92: 'chmod_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
93: 'chown_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
94: 'delete_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
95: 'fix_apache_perms_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
96: 'get_directory_contents_backend' => PRIVILEGE_SERVER_EXEC | PRIVILEGE_ALL,
97: 'get_file_contents_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
98: 'lookup_chroot_pwnam' => PRIVILEGE_SERVER_EXEC,
99: 'move_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
100: 'put_file_contents_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
101: 'report_quota' => PRIVILEGE_SITE,
102: 'report_quota_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
103: 'shadow_buildup_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
104: 'stat_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
105: 'takeover_user' => PRIVILEGE_SITE,
106: 'scan' => ANTIVIRUS_INSTALLED ? PRIVILEGE_SITE : PRIVILEGE_NONE
107: );
108: if (DEMO_ADMIN_LOCK && posix_getuid()) {
109: $this->exportedFunctions['*'] &= ~PRIVILEGE_ADMIN;
110: }
111: $this->__wakeup();
112: }
113:
114: public function __wakeup()
115: {
116: $this->cached = Cache_User::spawn($this->getAuthContext());
117: }
118:
119: /**
120: * Transform relative path into an absolute path
121: *
122: * @param string $file absolute location of a path
123: * @param string $referent relative path
124: * @return string
125: */
126: public function convert_relative_absolute($file, $referent)
127: {
128: return \Opcenter\Filesystem::rel2abs($file, $referent);
129: }
130:
131: /**
132: * array get_registered_extensions ()
133: * Keys from the return value correspond to the extension type,
134: * values are the classes all implementing the common interface
135: * Compression_Interface
136: *
137: * @return array list of known compression extensions and their corresponding
138: * interfaces.
139: */
140: public function get_registered_extensions()
141: {
142: return self::$registered_extensions;
143: }
144:
145: /**
146: * Extract files from archive
147: *
148: *
149: * @param string $archive archive file
150: * @param string $dest destination directory
151: * @param bool $overwrite overwrite destination files if source exists
152: * @return bool
153: */
154: public function extract($archive, $dest, $overwrite = true)
155: {
156: if (!IS_CLI) {
157: $ret = $this->query('file_extract', $archive, $dest);
158:
159: return $ret;
160: }
161:
162: $class = $this->initialize_interface($archive);
163: $archive_path = $this->make_path($archive);
164: $destination_path = $this->make_path($dest);
165: $tmp_path = $this->_mktmpdir(storage_path('tmp'), 'ee');
166: if ($archive_path instanceof Exception) {
167: return $archive_path;
168: } else if ($destination_path instanceof Exception) {
169: return $destination_path;
170: } else if (!strpos($tmp_path, '/', 1)) {
171: return error('path creation failure');
172: }
173:
174: $archive_stat = $this->stat_backend($archive, false);
175: $destination_stat = $this->stat_backend($dest, false);
176: if (!file_exists($destination_path) && !$this->create_directory($dest, 0755, true)) {
177: return false;
178: } else if (!$this->hasAccessRights($destination_path, POSIX_W_OK|POSIX_R_OK)) {
179: return error($dest . ': unable to write to directory');
180: }
181: if ($archive_stat instanceof Exception) {
182: return $archive_stat;
183: } else {
184: if ($destination_stat instanceof Exception && !$destination_stat instanceof FileError) {
185: return $destination_stat;
186: }
187: }
188:
189: mkdir($tmp_path);
190: chmod($tmp_path, 0700);
191: $ret = $class->extract_files($archive_path, $tmp_path);
192: if (!$ret || $ret instanceof Exception) {
193: \Opcenter\Filesystem::rmdir($tmp_path);
194: return $ret;
195: }
196:
197:
198: $ret = 0;
199: $flags = '-aHWxq';
200: if (!$overwrite) {
201: $flags .= ' --ignore-existing';
202: }
203: $proc = Util_Process_Safe::exec(
204: '/bin/chown -h -R %s:%s %s && rsync ' . $flags . ' %s/ %s/ && rm -rf %s/',
205: $this->user_id, $this->group_id, $tmp_path, $tmp_path, $destination_path, $tmp_path
206: );
207:
208: chmod($destination_path, 0755);
209:
210: return $proc['success'];
211: }
212:
213: /**
214: * Initialize compression driver for file
215: *
216: * @private
217: * @param string $file
218: * @return object class instance
219: */
220: private function initialize_interface($file)
221: {
222: if (!$this->is_compressed($file)) {
223: return error($file . ': not a recognized compressed file');
224: }
225: $ext = substr($this->compression_extension($file), 1);
226: if (!$ext) {
227: return error($file . ': internal error determining archive extension');
228: }
229: if (isset($this->compression_instances[$ext])) {
230: return $this->compression_instances[$ext];
231: }
232:
233: $base_dir = INCLUDE_PATH . '/lib/modules/compression/';
234: $module = self::$registered_extensions[$ext];
235: if (!file_exists($base_dir . '/' . $module . '.php')) {
236: return error($module . ': compression filter not found');
237: }
238:
239: if (!class_exists(ucwords($module) . '_Filter', false)) {
240: if (!interface_exists('IArchive', false)) {
241: include($base_dir . '/iarchive.php');
242: }
243: if (!class_exists('Archive_Base', false)) {
244: include($base_dir . '/base.php');
245: }
246: include($base_dir . '/' . $module . '.php');
247: }
248:
249: $c = ucwords($module) . '_Filter';
250: $class = new $c($this);
251:
252: $class->init($this);
253: $this->compression_instances[$ext] = $class;
254:
255: return $this->compression_instances[$ext];
256: }
257:
258: /**
259: * bool is_compressed (string)
260: * Checks to see if a file is a compressed archive through a
261: * bit of guestimation
262: *
263: * @param string $mFile
264: * @return bool
265: */
266: public function is_compressed($mFile)
267: {
268: $extTmp = explode('.', $mFile);
269: $ext = array_pop($extTmp);
270: /** may be .tar.gz for example */
271: if (isset(self::$registered_extensions[$ext])) {
272: return true;
273:
274: }
275: $ext2 = array_pop($extTmp);
276: return isset(self::$registered_extensions[implode('.', array($ext2, $ext))]);
277: }
278:
279: /**
280: * Extract compression extension from file
281: *
282: * @param $file
283: * @return string
284: */
285: public function compression_extension($file)
286: {
287: if (!$this->is_compressed($file)) {
288: return false;
289: }
290:
291: $extTmp = explode('.', $file);
292: if (sizeof($extTmp) > 2) {
293: $ext = join('.', array_slice($extTmp, -2));
294: }
295:
296: if (sizeof($extTmp) <= 2 || !isset(self::$registered_extensions[$ext])) {
297: $ext = join('', array_slice($extTmp, -1));
298: }
299:
300: $this->compression_ext = $ext;
301:
302: return '.' . $ext;
303: }
304:
305: /**
306: * Returns a path outside the chroot'd environment
307: *
308: * @TODO tokenize
309: *
310: * @param string $path
311: * @param string $link translated symbolic link path
312: * @return string|false
313: */
314: public function make_path(string $path, ?string &$link = '')
315: {
316: if (isset($this->trans_paths[$this->site_id][$path])) {
317: $path = $this->trans_paths[$this->site_id][$path];
318: $link = $path[1];
319:
320: return $path[0];
321: }
322: // we really don't know how to handle relative files
323:
324: if (!isset($path[0])) {
325: return $this->domain_fs_path();
326: } else if ($path[0] === '~') {
327: $path = $this->user_get_home() . substr($path, 1);
328: } else if ($path[0] !== '/') {
329: // bubble up for brevity
330: throw new \Exception($path . ': path must be absolute');
331: }
332: $root = '';
333: $newpath = str_replace('//', '/', $path);
334: $link = '';
335:
336: if (($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER))) {
337: $root = $this->domain_fs_path();
338: }
339:
340: if (\Util_PHP::is_link($root . $newpath)) {
341: $link = $root . $newpath;
342: if (file_exists($link) && (string)readlink($link)[0] == '/') {
343: $newpath = realpath($link);
344: } else {
345: $tmp = (string)realpath($link);
346: if (0 === strpos($tmp, $root)) {
347: $newpath = substr($tmp, strlen($root));
348: }
349: }
350: }
351:
352: for ($pathCom = explode('/', $newpath), $i = sizeof($pathCom); $i > 0; $i--) {
353: $pathTest = $root . implode('/', array_slice($pathCom, 0, $i));
354: if (file_exists($pathTest)) {
355: break;
356: }
357: }
358:
359: if (false === ($fstPath = realpath($pathTest))) {
360: // @XXX delayed callback fired after site deletion
361: return $this->domain_fs_path();
362: }
363:
364: if (isset($root[1]) &&
365: 0 !== strpos($fstPath, $root)
366: ) {
367: // let's assume they made a symlink to /var/www/html/ instead of ../../var/www/
368: //if (!file_exists($this->domain_fs_path().realpath($pathTest)))
369:
370: $newpath = $root . $pathTest;
371: //return new FileError("Invalid path detected");
372: }
373: $newpath = $root . str_replace('//', '/', $newpath);
374: if (!isset($this->trans_paths[$this->site_id])) {
375: $this->trans_paths[$this->site_id] = array();
376: }
377: $this->trans_paths[$this->site_id][$path] = array($newpath, $link);
378: $this->trans_paths[$newpath] = $path;
379:
380: return $newpath;
381: }
382:
383: protected function _mktmpdir($path, $prefix = '')
384: {
385: $dir = $path . '/' . uniqid($prefix);
386: if (file_exists($dir)) {
387: return $this->_mktmpdir($path, $prefix);
388: }
389:
390: return $dir;
391: }
392:
393: /**
394: * Give information about a file
395: *
396: * @see stat()
397: * @param string $file
398: * @return array
399: */
400: public function stat_backend(string $file, bool $shadow = true): ?array
401: {
402: $link = '';
403: if (~$this->permission_level & PRIVILEGE_ADMIN) {
404: $path = $shadow ? $this->make_shadow_path($file, $link) : $this->make_path($file, $link);
405: } else {
406: $path = $file;
407: }
408:
409: if (!$path) {
410: return nerror("failed to translate path `%s'", $path);
411: }
412:
413: if (!$link && !file_exists($path)) {
414: if (strlen($path) > PATH_MAX) {
415: return nerror("Path exceeds filesystem availability");
416: }
417: return array();
418: }
419: $prefix = '';
420: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
421: $prefix = $shadow ? $this->domain_shadow_path() : $this->domain_fs_path();
422: }
423:
424: if ($this->clearstat) {
425: clearstatcache(false);
426: $this->clearstat = false;
427: }
428:
429: return $this->vfsStat($link ?: $path, $prefix, $shadow);
430: }
431:
432: private function vfsStat(string $path, string $prefix, bool $shadow): array
433: {
434: $pathbase = dirname($path);
435: $file = basename($path);
436:
437: $portable_link = true;
438: /**
439: * PHP 7.x ZTS regression, "." will always refer to the current
440: * directory and a link cannot be a directory, thus
441: * \Util_PHP::is_link("/home/symlink/.") should be false as in non-ZTS builds
442: */
443: $islink = $file !== '.' && \Util_PHP::is_link($path);
444:
445: if ($islink === false) {
446: $stat_details = stat($path);
447: } else {
448: $link = readlink($path);
449: // reprocess path to resolve symlink
450: $referent = $this->{$shadow ? 'make_shadow_path' : 'make_path'}(substr($path, strlen($prefix)));
451: $vreferent = substr($referent, strlen($prefix));
452: if (!$vreferent || !file_exists($referent)) {
453: $vreferent = null;
454: $portable_link = 0;
455: } else {
456: $portable_link = $link[0] !== '/';
457: }
458: $link_type = $referent && is_dir($referent) ? 2 : 1;
459: $refstat = [];
460: if (file_exists($path)) {
461: $refstat = stat($path);
462: }
463: $stat_details = array_intersect_key($refstat, self::LINK_KEYMAP) + lstat($path);
464: }
465:
466: if (empty($stat_details)) {
467: // pending propagation to upper vfs layer, direct shadow write
468: return $shadow ? [] : $this->vfsStat($path, $prefix, true);
469: }
470:
471: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
472: // fetch uid/gid from chroot'd filesystem
473: $owner = $this->lookup_chroot_pwnam($stat_details['uid']);
474: $group = $this->lookup_chroot_pwnam($stat_details['gid']);
475: } else {
476: // otherwise we can use normal posix functions to query /etc/passwd
477: $owner = posix_getpwuid($stat_details['uid']);
478: $owner = $owner['name'];
479: $group = posix_getgrgid($stat_details['gid']);
480: $group = $group['name'];
481: }
482:
483: $acl = 0;
484: // next gather the ACLs on the file
485: // if file is a symbolic link, then skip it
486: if (!$islink && $pathbase !== '.') {
487: $acls = $this->get_acls(substr($path, strlen($prefix)));
488: if ($acls) {
489: $pwusr = $this->lookup_chroot_pwnam($this->user_id);
490: $pwgrp = $this->lookup_chroot_pwnam($this->group_id);
491: foreach ($acls as $item) {
492: if (isset($item['user']) && $item['user'] == $pwusr) {
493: $acl = $item['permissions'];
494: break;
495: }
496:
497: if (isset($item['group']) && $item['group'] == $pwgrp) {
498: $acl = $item['permissions'];
499: }
500: }
501: }
502: }
503:
504: return array(
505: 'filename' => $file,
506: 'owner' => $owner ?: $stat_details['uid'],
507: 'group' => $group ?: $stat_details['gid'],
508: 'uid' => $stat_details['uid'],
509: 'gid' => $stat_details['gid'],
510: 'size' => $stat_details['size'],
511: 'file_type' => $islink ? 'link' : $this->filetypeFromMode($stat_details['mode']),
512: 'referent' => $islink ? $vreferent : null,
513: 'portable' => $portable_link,
514: 'link' => $islink ? $link_type : 0,
515: 'nlinks' => $stat_details['nlink'],
516: 'permissions' => $islink ? 41471 : $stat_details['mode'],
517: 'site_quota' => $stat_details['gid'] === $this->group_id,
518: 'user_quota' => $stat_details['uid'] === $this->user_id,
519: 'ctime' => $stat_details['ctime'],
520: 'mtime' => $stat_details['mtime'],
521: 'atime' => $stat_details['atime'],
522: 'inode' => $stat_details['ino'],
523: 'sid' => $this->site_id,
524: 'can_write' => $acl & 2 ||
525: ($this->permission_level & PRIVILEGE_SITE) &&
526: ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID /* apache */) ||
527: $stat_details['uid'] == $this->user_id && $stat_details['mode'] & 0x0080 ||
528: ($stat_details['gid'] == $this->group_id && $stat_details['mode'] & 0x0010) &&
529: !($stat_details['mode'] & 0x0200) ||
530: $stat_details['gid'] != $this->group_id && $stat_details['mode'] & 0x0002,
531:
532: 'can_read' => $acl & 4 ||
533: ($this->permission_level & PRIVILEGE_SITE) &&
534: ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID) ||
535: $stat_details['uid'] == $this->user_id && $stat_details['mode'] & 0x0100 ||
536: $stat_details['gid'] == $this->group_id && $stat_details['mode'] & 0x0020 ||
537: $stat_details['gid'] != $this->group_id && $stat_details['mode'] & 0x0004,
538:
539: 'can_execute' => $acl & 1 ||
540: ($this->permission_level & PRIVILEGE_SITE) &&
541: ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID) ||
542: $stat_details['uid'] == $this->user_id && $stat_details['mode'] & 0x0040 ||
543: $stat_details['gid'] == $this->group_id && $stat_details['mode'] & 0x0008 ||
544: $stat_details['gid'] != $this->group_id && $stat_details['mode'] & 0x0001,
545:
546: 'can_chown' => ($this->permission_level & PRIVILEGE_SITE) && /** super user */
547: ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID ||
548: ($stat_details['gid'] == APACHE_UID)) ||
549: $this->permission_level & PRIVILEGE_USER && $stat_details['uid'] == $this->user_id,
550:
551: 'can_chgrp' => (bool)($this->permission_level & PRIVILEGE_ADMIN)
552: );
553: }
554:
555: private function filetypeFromMode(int $mode): string
556: {
557: switch ($mode & 00170000) {
558: case 0100000:
559: return 'file';
560: case 0040000:
561: return 'dir';
562: case 0120000:
563: return 'link';
564: case 0140000:
565: return 'socket';
566: case 0060000:
567: return 'block';
568: case 0020000:
569: return 'char';
570: case 0010000:
571: return 'fifo';
572: default:
573: return 'unknown';
574: }
575: }
576:
577: /**
578: * Resolve path as shadow
579: *
580: * @param $path
581: * @param string $link
582: * @return string
583: */
584: public function make_shadow_path($path, &$link = '')
585: {
586: $path = $this->make_path($path, $link);
587: $prefix = $this->domain_fs_path();
588:
589: if ($link) {
590: $link = $this->domain_shadow_path() . substr($link, strlen($prefix));
591: }
592: return $this->domain_shadow_path() . substr($path, strlen($prefix));
593: }
594:
595: /**
596: * mixed lookup_chroot_pwnam (integer)
597: *
598: * @param integer $uid user id to lookup
599: * @return string transformed name of the uid.
600: * Transforms the uid the username within a chroot'd environment
601: */
602: private function lookup_chroot_pwnam($uid)
603: {
604: if (!$uid) {
605: return 'root';
606: }
607: if (!isset($this->uid_translation[$uid])) {
608: $this->uid_translation[$uid] = $this->user_get_username_from_uid($uid);
609: }
610:
611: return $this->uid_translation[$uid];
612: }
613:
614: /**
615: * Perform ACL lookup on files
616: *
617: * @param string $file filename
618: * @return array
619: */
620: public function get_acls($file)
621: {
622: if (0 === strncmp($file, "/proc", 5)) {
623: return array();
624: } else if (!IS_CLI) {
625: $ret = $this->query('file_get_acls', $file);
626:
627: return $ret;
628: }
629:
630: $optimized = false;
631: if ($this->permission_level & PRIVILEGE_SITE) {
632: $optimized = $this->_optimizedShadowAssertion;
633: }
634:
635: if ($optimized) {
636: // only copy off shadow, if in the future ro -> rw branch
637: // propagation can setgid copy-ups, then maybe switch back to make_path()
638: // do not use shadow path as dest if luna+ (OverlayFS)
639: $path = $this->make_shadow_path($file);
640: } else if ($this->permission_level & PRIVILEGE_USER) {
641: $path = $this->make_path($file);
642: } else if ($this->permission_level & PRIVILEGE_ADMIN) {
643: $path = $file;
644: }
645:
646: if (!$path) {
647: return $path;
648: }
649:
650: $cache_key = $this->site_id . '|' . ($cacheDir = dirname($file));
651: $apcu_key = ['acl', $cacheDir];
652: $acl_dir = $path;
653:
654: if (!isset($this->acl_cache[$cache_key])) {
655: $acl_dir = dirname($path);
656: $cache = \Cache_Account::spawn($this->getAuthContext());
657: $entry = $cache->hGet(...$apcu_key);
658:
659: if (false !== $entry) {
660: $this->acl_cache = array_merge_recursive($this->acl_cache,
661: [$cache_key => $entry]);
662:
663: return $entry[basename($path)] ?? [];
664: }
665: }
666:
667: // acl updates only happen through one command
668: if (isset($this->acl_cache[$cache_key])) {
669: return $this->acl_cache[$cache_key][basename($file)]['aclinfo'] ?? [];
670: }
671:
672: if (!is_readable($path)) {
673: return [];
674: }
675:
676: if (!is_dir($acl_dir)) {
677: $acl_dir = dirname($path);
678: }
679:
680: // conceal any warnings about missing files in the glob pattern
681: $path_safe = escapeshellarg($acl_dir);
682: $path_safe = str_replace('%', '%%', $path_safe);
683: // ignore non-ACL entries
684: $cmd = sprintf('getfacl --skip-base --absolute-names --omit-header --numeric --tabular ' .
685: '--all-effective %s/ %s/.[!.]* %s/..?* %s/*',
686: $path_safe,
687: $path_safe,
688: $path_safe,
689: $path_safe);
690: $data = Util_Process::exec($cmd, array(0, 1), array('mute_stderr' => true));
691: /**
692: * @TODO: Cache expensive ACL lookups
693: */
694:
695: $isChroot = $this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER);
696: $data['output'] = preg_replace_callback('/\\\\(\d{3})/',
697: static function ($match) {
698: return chr(octdec($match[1]));
699: }, $data['output']);
700: //if (!$data['output']) {
701: // warn($cmd.": no response");
702: //}
703:
704: $acl_cache = array();
705: foreach (explode("\n\n", $data['output']) as $entry) {
706: if (0 !== strncmp($entry, '# file:', 7)) {
707: continue;
708: }
709: $acls = array();
710: $entpath = (string)substr($entry, 8, strpos($entry, "\n") - 8);
711: if (strrchr($entpath, '/') == '/.' || strrchr($entpath, '/') == '/..') {
712: continue;
713: }
714:
715: /** skip .. and . entries */
716: foreach (explode("\n", $entry) as $line) {
717: if (preg_match(Regex::GETFACL_ACL, $line, $aclMatches)) {
718: $perms = 0;
719: if ($aclMatches[1] == 'USER') {
720: $type = 'euser';
721: } else if ($aclMatches[1] == 'GROUP') {
722: $type = 'egroup';
723: } else {
724: $type = $aclMatches[1];
725: }
726:
727: if (strtolower($aclMatches[3][0]) == 'r') {
728: $perms |= 4;
729: }
730: if (strtolower($aclMatches[3][1]) == 'w') {
731: $perms |= 2;
732: }
733: if (strtolower($aclMatches[3][2]) == 'x') {
734: $perms |= 1;
735: }
736: $identifier = $isChroot ? $this->lookup_chroot_pwnam((int)$aclMatches[2]) : $aclMatches[2];
737: if (($type === 'egroup' || $type === 'group') && $aclMatches[2] == $this->group_id) {
738: $identifier = array_get(posix_getgrgid($this->group_id), 'name');
739: }
740: $acls[] = array(
741: $type => $identifier,
742: 'permissions' => $perms
743: );
744: }
745: }
746: $aclkey = basename($entpath);
747: $acl_cache[$cache_key][$aclkey] = array(
748: 'mtime' => filemtime($entpath),
749: 'aclinfo' => $acls
750: );
751: }
752: $cache = \Cache_Account::spawn($this->getAuthContext());
753: if (!$cache->exists($apcu_key[0])) {
754: defer($_, static function () use ($cache, $apcu_key) {
755: $cache->expire($apcu_key[0], 60);
756: });
757: }
758: $cache->hSet($apcu_key[0], $apcu_key[1], $acl_cache);
759: $this->acl_cache = array_merge($this->acl_cache, $acl_cache);
760:
761: return $this->acl_cache[$cache_key][basename($file)]['aclinfo'] ?? [];
762: }
763:
764: private function _getCacheKey($file)
765: {
766: $cachebase = $this->_getCacheDir($file);
767:
768: return 's:' . md5($cachebase);
769: }
770:
771: private function _getCacheDir($file)
772: {
773: return dirname($file);
774: }
775:
776: /**
777: * bool create_directory (string[, int[, bool]])
778: * Creates a directory within the filesystem and will recursively
779: * create parent directories if need be
780: *
781: * @param string $dir directory name to create
782: * @param integer $mode mode to create the file
783: * @param bool $recursive recursively create directory
784: * @return bool
785: */
786: public function create_directory($dir, $mode = 0755, $recursive = false): bool
787: {
788: if (!is_int($mode)) {
789: return error($mode . ': invalid mode');
790: }
791: if (!IS_CLI) {
792: return $this->query('file_create_directory', $dir, $mode, $recursive);
793: }
794:
795: $path = $this->make_path($dir);
796: clearstatcache(true, $path);
797: $dir2mk = array();
798: if (!$recursive && !file_exists(dirname($path))) {
799: return error(dirname($dir) . ': no such file/directory');
800: }
801: if (file_exists($path)) {
802: if (is_dir($path)) {
803: return true;
804: } else {
805: return warn('%s: file exists', $dir);
806: }
807: }
808: //
809:
810: $dir = $this->unmake_path($path);
811: $curpath = '';
812: $curdir = strtok($dir, '/');
813: $pathpfx = $this->domain_fs_path();
814:
815: do {
816: $curpath .= '/' . $curdir;
817: $fullpath = $pathpfx . $curpath;
818: if (!file_exists($fullpath)) {
819: $dir2mk[] = $fullpath;
820: }
821: } while (false !== ($curdir = (strtok('/'))));
822: if (!$dir2mk) {
823: // @XXX weird aufs bug, initial stat reports no
824: // but incremental dir buildup reports file_exists()
825: // possible delay in cache?
826: // triggered in litmus basic test no 6,
827: // "mkcol_over_plain" -> mkdir over file
828: return is_dir($fullpath);
829: }
830: $parent = dirname($dir2mk[0]);
831: $pstat = $this->stat_backend($this->unmake_path($parent));
832:
833: if ($pstat instanceof Exception) {
834: throw $pstat;
835: }
836:
837: // check to see if can access parent
838: if (!$pstat['can_write'] || !$this->hasAccessRights($parent)) {
839: return error('%s: permission denied', $this->unmake_path($parent));
840: }
841:
842: foreach ($dir2mk as $newdir) {
843: $res = \Opcenter\Filesystem::mkdir(
844: $newdir, $this->user_id, $this->group_id, $mode
845: );
846:
847: if (!$res) {
848: return error('%s: cannot create directory', $this->unmake_path($newdir));
849: }
850: }
851:
852: return true;
853: }
854:
855: /**
856: * Create a temporary file
857: *
858: * Requires manual removal of file.
859: *
860: * @param string $base
861: * @param string $prefix
862: * @return string|null
863: */
864: public function temp(string $base, string $prefix = 'apiscp-temp'): ?string
865: {
866: if (!IS_CLI) {
867: return $this->query('file_temp', $base, $prefix);
868: }
869:
870: $path = realpath($this->getAuthContext()->domain_fs_path($base));
871: if (0 !== strpos($path, $this->domain_fs_path())) {
872: error("Specified path invalid");
873: return null;
874: }
875:
876: if (!is_dir($base)) {
877: error("\$base `%s' is not a directory", $base);
878: }
879:
880: if (0 !== strpos($created = tempnam($path, $prefix), $this->domain_fs_path())) {
881: error("failed to create temporary file within requested base");
882: file_exists($created) && unlink($created);
883: return null;
884: }
885:
886: if (!Opcenter\Filesystem::chogp($created, $this->user_id, $this->group_id, 0600)) {
887: return null;
888: }
889: return $this->unmake_path($created);
890: }
891:
892: /**
893: * string unmake_path(string $mPath)
894: * Complimentary function to make_path
895: *
896: * @param string $path
897: * @return string chroot'd path
898: */
899: public function unmake_path(string $path): string
900: {
901: // admin always has root access
902: if ($this->permission_level & PRIVILEGE_ADMIN) {
903: return $path;
904: }
905: $path = str_replace('//', '/', $path);
906: $offset = 0;
907: if (($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) &&
908: 0 === strpos($path, $this->domain_fs_path()))
909: {
910: $offset = strlen($this->domain_fs_path());
911: }
912:
913: return '/' . ltrim(substr($path, $offset), '/');
914: }
915:
916: /**
917: * Gives information about a file
918: *
919: * Peforms a stat() request on named file
920: *
921: * array(22) {
922: * ["owner"]=> string(5) "debug"
923: * ["group"]=> string(5) "group"
924: * ["uid"]=> int(664)
925: * ["gid"]=> int(664)
926: * ["size"]=> int(4096)
927: * ["file_type"]=> string(3) "dir"
928: * ["referent"]=> NULL
929: * ["portable"]=> bool(true)
930: * ["link"]=> int(0)
931: * ["nlinks"]=> int(4)
932: * ["permissions"]=> int(16889)
933: * ["site_quota"]=> bool(true)
934: * ["user_quota"]=> bool(true)
935: * ["ctime"]=> int(1242769316)
936: * ["mtime"]=> int(1242769316)
937: * ["atime"]=> int(1230433552)
938: * ["inode"]=> int(2454742)
939: * ["can_write"]=> bool(true)
940: * ["can_read"]=> bool(true)
941: * ["can_execute"]=> bool(true)
942: * ["can_chown"]=> bool(true)
943: * ["can_chgrp"]=> bool(true)
944: * }
945: *
946: * owner: resolved name of the uid
947: * group: resolved name of the gid
948: * uid: numeric user id
949: * gid: numeric group id
950: * size: file size in bytes
951: * file_type: file type, values [dir, file, link]
952: * link: file is symbolic link, 2 if directory, 1 if file
953: * nlinks: number of hardlinks to the file
954: * permissions: file permissions encoded as integer
955: * site_quota: whether the file counts towards the site's quota
956: * user_quota: whether the file counts towards the user's quota
957: * ctime: creation time
958: * mtime: modification time
959: * atime: last access time
960: * inode: filesystem inode
961: * can_write: file has write bit set
962: * can_read: file has read bit set
963: * can_execute: file has execute bit set
964: * can_chown: user privileged to chown()
965: * can_chgrp: user privileged to chgrp()
966: *
967: * @param string $file
968: * @return array
969: */
970: public function stat($file)
971: {
972: return $this->query('file_stat_backend', $file, true);
973: }
974:
975: /**
976: * bool can_descend (string)
977: *
978: * @param string $path fully resolved path
979: * @param int $mask bitwise access rights calculation
980: * @param bool $direxists require that directory exists
981: * break before encountered
982: */
983: private function hasAccessRights(string $path, int $mask = POSIX_R_OK, bool $direxists = true): bool
984: {
985: $fspfx = $this->domain_fs_path();
986: if (0 !== strpos($path, $fspfx)) {
987: if (0 !== strpos($path, ($fspfx = $this->domain_shadow_path()))) {
988: return error($path . ': not fully qualified path');
989: }
990: }
991: // directory components to examine, assume fs prefix is immutable
992: $dirchk = substr($path, strlen($fspfx));
993: $subdir = strtok($dirchk, '/');
994: $curpath = '';
995: do {
996: $curpath .= '/' . $subdir;
997: $fullpath = $fspfx . $curpath;
998: if (!file_exists($fullpath)) {
999: if ($direxists) {
1000: return false;
1001: } else {
1002: break;
1003: }
1004: }
1005: if (is_link($fullpath)) {
1006: $fullpath = \realpath($fullpath);
1007: if (0 !== strpos($fullpath, $fspfx)) {
1008: return error('Corrupted path detected');
1009: }
1010: }
1011:
1012: if (!($stat = $this->stat_backend(substr($fullpath, strlen($fspfx)), false))) {
1013: return false;
1014: }
1015:
1016: // ($mask & POSIX_X_OK) implied
1017: if ($stat['file_type'] === 'dir' && !$stat['can_execute']) {
1018: return false;
1019: }
1020: } while (false !== ($subdir = strtok('/')));
1021:
1022: // read only affects listing of directory
1023: if (($mask & POSIX_R_OK) && !$stat['can_read']) {
1024: return false;
1025: }
1026:
1027: if (($mask & POSIX_W_OK) && !$stat['can_write']) {
1028: return false;
1029: }
1030:
1031: if (($mask & POSIX_X_OK) && !$stat['can_execute']) {
1032: return false;
1033: }
1034:
1035: return true;
1036: }
1037:
1038: /**
1039: * Calculate etag of a file
1040: *
1041: * @param string $file
1042: * @return null|string
1043: */
1044: public function etag($file)
1045: {
1046: $stat = $this->file_stat($file);
1047: if (!$stat) {
1048: return null;
1049: }
1050:
1051: return sha1($stat['inode'] . $stat['size'] . $stat['mtime']);
1052: }
1053:
1054: /**
1055: * Make a protected file ephemerally accessible by apnscp
1056: *
1057: * @xxx dangerous
1058: * File is removed on script end
1059: *
1060: * @param string $file
1061: * @param string $mode read or write
1062: * @return string|bool file
1063: */
1064: public function expose($file, $mode = 'read')
1065: {
1066: if (!IS_CLI) {
1067: $clone = $this->query('file_expose', $file, $mode);
1068: // always ensure this
1069: if ($clone) {
1070: register_shutdown_function(function ($clone, $prefix) {
1071: if (file_exists($prefix . $clone)) {
1072: $this->file_set_acls(
1073: $clone,
1074: posix_getpwnam(APNSCP_SYSTEM_USER)['uid']
1075: );
1076: $this->file_delete($clone);
1077: }
1078: }, $clone, $this->domain_fs_path());
1079: }
1080:
1081: return $clone;
1082: }
1083:
1084: if ($mode !== 'read' && $mode !== 'write') {
1085: return error("unknown mode `%s'", $mode);
1086: }
1087:
1088: $stat = $this->stat_backend($file, false);
1089: if (!$stat['can_' . $mode]) {
1090: return error("cannot access file `%s'", $file);
1091: } else if ($stat['file_type'] !== 'file') {
1092: return error("file `%s' is not a regular file", $file);
1093: } else if ($stat['nlinks'] > 1) {
1094: return error("file `%s' must not be linked elsewhere", $file);
1095: }
1096:
1097: $tmppath = $this->make_path(TEMP_DIR);
1098: $tempnam = tempnam($tmppath, 'ex');
1099: unlink($tempnam);
1100: $path = $this->make_path($file);
1101: link($path, $tempnam);
1102: clearstatcache(true, $tempnam);
1103: if ($stat['inode'] !== fileinode($tempnam)) {
1104: error("possible race condition, expected ino `%d', got `%d' - removing `%s'",
1105: $stat['inode'], fileinode($tempnam), $tempnam);
1106: unlink($tempnam);
1107:
1108: return false;
1109: }
1110: $resolved = $this->unmake_path($tempnam);
1111: clearstatcache(true, $path);
1112: $this->_purgeCache($file);
1113: if (!$this->set_acls($resolved, posix_getpwnam(APNSCP_SYSTEM_USER)['uid'], $mode === 'read' ? 4 : 6)) {
1114: file_exists($tempnam) && unlink($tempnam);
1115: return error("Failed to apply ACLs");
1116: }
1117:
1118: return $resolved;
1119: }
1120:
1121: /**
1122: * Remove files from cache
1123: *
1124: * @param array|string $files
1125: * @return bool
1126: */
1127: private function _purgeCache($files)
1128: {
1129: $purged = array();
1130: $siteid = $this->site_id;
1131: $path = $this->domain_fs_path();
1132: foreach ((array)$files as $f) {
1133: $dir = dirname($f);
1134: $hash = md5($dir);
1135: clearstatcache(true, $path . $f);
1136: $this->trans_paths[$this->site_id][$f] = null;
1137: if (isset($purged[$hash])) {
1138: continue;
1139: }
1140: $this->stat_cache[$siteid][$hash] = null;
1141: $this->cached->delete('s:' . $hash);
1142: $purged[$hash] = 1;
1143: }
1144: if (count($purged) > 1) {
1145: $this->clearstat = true;
1146: }
1147:
1148: return true;
1149: }
1150:
1151: /**
1152: * List contents of a compressed file
1153: *
1154: * @param string $file file name
1155: * @return array
1156: */
1157: public function get_archive_contents($file)
1158: {
1159: if (!IS_CLI) {
1160: return $this->query('file_get_archive_contents', $file);
1161: }
1162:
1163: $path = $this->make_path($file);
1164: $stat = $this->stat_backend($file, false);
1165:
1166: if ($path instanceof Exception) {
1167: return $path;
1168: }
1169: if ($stat instanceof Exception) {
1170: return $stat;
1171: }
1172:
1173: $class = $this->initialize_interface($path);
1174: if ($class instanceof Exception || !$class) {
1175: return $class;
1176: }
1177:
1178: $files = $class->list_files($path);
1179: Util_Conf::sort_files($files, 'value', true);
1180:
1181: return $files;
1182: }
1183:
1184: /**
1185: * Copy files
1186: *
1187: * @param string|array $source array of source files or directories to copy
1188: * @param string $dest destination directory or file
1189: * @param int $force overwrite destination with source if exists
1190: * @param int $recursive recursively copy directory contents
1191: * @param int $prune remove target before copying
1192: * @return bool
1193: */
1194: public function copy($source, $dest, $force = true, $recursive = true, $prune = false, int $depth = 0)
1195: {
1196: if (!IS_CLI) {
1197: if (!$source || !$dest) {
1198: return error('invalid source or destination');
1199: }
1200: $res = $this->query('file_copy', $source, $dest, $force, $recursive, $prune);
1201: if ($res) {
1202: $this->_purgeCache($source);
1203: $this->_purgeCache($dest);
1204: }
1205: return $res;
1206: }
1207: if ($this->permission_level & PRIVILEGE_SITE) {
1208: $optimized = $this->_optimizedShadowAssertion;
1209: } else {
1210: $optimized = false;
1211: }
1212:
1213: if (!is_array($source)) {
1214: $source = array($source);
1215: }
1216:
1217: if ($optimized && $optimized !== 2) {
1218: // only copy off shadow, if in the future ro -> rw branch
1219: // propagation can setgid copy-ups, then maybe switch back to make_path()
1220: // do not use shadow path as dest if luna+ (OverlayFS)
1221: $dest_path = $this->make_shadow_path($dest);
1222: } else {
1223: $dest_path = $this->make_path($dest);
1224: }
1225:
1226: if (\Util_PHP::is_link($dest_path)) {
1227: $dest_path = readlink($dest_path);
1228:
1229: }
1230: $dest_parent = $dest_path;
1231:
1232: // destination is not a folder
1233: if (!file_exists($dest_path) || !is_dir($dest_path)) {
1234: if (count($source) > 1) {
1235: return error('copying multiple files but ' .
1236: "destination `%s' is not a directory", $dest);
1237: }
1238: $dest_parent = dirname($dest_path);
1239: }
1240: if (!file_exists($dest_parent)) {
1241: return error("destination `$dest_parent' does not exist");
1242: }
1243:
1244: // perform check on target to prevent copying over a dangerous .bashrc into /root
1245: // in a provisioned environment this shouldn't be the case; /root isn't setup
1246: $parent_stat = $this->stat_backend($this->unmake_path($dest_parent));
1247: if (!$parent_stat) {
1248: return false;
1249: }
1250: if (!$this->hasAccessRights($dest_parent, POSIX_W_OK | POSIX_R_OK, false)) {
1251: return error("accessing `$dest': permission denied");
1252: }
1253:
1254: $files_copied = 0; // number of files copied
1255: $pathChecks = [];
1256: for ($i = 0, $nsource = sizeof($source); $i < $nsource; $i++) {
1257: $link = '';
1258: $src_path = $optimized ? $this->make_shadow_path($source[$i], $link) :
1259: $this->make_path($source[$i], $link);
1260:
1261: if (strlen($source[$i]) <= 6) {
1262: return error('aborting operation for your own good! ' . var_export($source[$i]));
1263: }
1264:
1265: if ($link) {
1266: $files = (array)$link;
1267: } else if ($src_path === $dest_path) {
1268: warn('source directory `' .
1269: $this->unmake_path($src_path) . "' and destination are same");
1270: continue;
1271: } else if ($src_path[-1] !== '/') {
1272: $files = glob($src_path, GLOB_NOSORT);
1273: } else {
1274: $files = [];
1275: foreach (new DirectoryIterator($src_path) as $file) {
1276: if ($file->isDot()) {
1277: continue;
1278: }
1279: $files[] = $file->getPathname();
1280: }
1281: }
1282:
1283: for ($j = 0, $nfiles = sizeof($files); $j < $nfiles; $j++) {
1284: $file = $files[$j];
1285: if (!file_exists($file)) {
1286: continue;
1287: }
1288:
1289: $parent = dirname($file);
1290:
1291: if (!isset($pathChecks[$parent])) {
1292: $pathChecks[$parent] = $this->hasAccessRights(realpath($parent), POSIX_W_OK, false);
1293: }
1294:
1295: if (!$pathChecks[$parent]) {
1296: warn($file . ': cannot copy - access denied');
1297: continue;
1298: }
1299:
1300: if ($optimized) {
1301: $stat = stat($file);
1302: $lstat = lstat($file);
1303: $local_file = $this->unmake_shadow_path($file);
1304:
1305: if (!$stat || !$lstat ||
1306: !($stat['mode'] & ~0x3FFDB) && $stat['uid'] < \User_Module::MIN_UID)
1307: {
1308: // assume if g/o lack read (4) then it's protected
1309: $files_copied = 0;
1310: error("cannot read `%s'", $local_file);
1311: continue;
1312: }
1313:
1314: // create a stat array with bare minimums
1315: $fstat = array(
1316: 'file_type' => $this->filetypeFromMode($lstat['mode']),
1317: 'permissions' => $stat['mode'],
1318: 'referent' => (($lstat['mode'] & 0xF000 & 0xA000) === 0xA000) ? readlink($file) : null
1319: );
1320: } else {
1321: $local_file = $this->unmake_path($file);
1322: $fstat = $this->stat_backend($local_file);
1323: #fwrite(STDERR, "F: (".$fstat['file_type'].")".$file."\n");
1324: if ($fstat instanceof Exception) {
1325: return $fstat;
1326: }
1327: if (!$fstat['can_read']) {
1328: $files_copied = 0;
1329: error("cannot read `$local_file'");
1330: continue;
1331: }
1332: }
1333:
1334: if ($fstat['file_type'] !== 'dir') {
1335: // copy file to newfile
1336: $newfile = $dest_path;
1337: if ($dest_parent == $dest_path) {
1338: $newfile .= '/' . basename($local_file);
1339: }
1340: if (is_dir($newfile)) {
1341: //fwrite(STDERR, "WTF\n\n\n");
1342: $newfile .= basename($local_file);
1343: }
1344: if ($file === $newfile) {
1345: warn('source `' . basename($file) . "' destination same");
1346: continue;
1347: }
1348: if ($fstat['file_type'] === 'link') {
1349: if ($depth === 0) {
1350: // surface copy
1351: $tmp = $this->file_unmake_path($newfile);
1352: $files_copied += $this->copy(
1353: Opcenter\Filesystem::rel2abs($tmp, $fstat['referent']),
1354: $tmp,
1355: $force,
1356: $recursive,
1357: $prune,
1358: $depth
1359: );
1360: } else {
1361: // copy just link
1362: $this->file_symlink($newfile, $fstat['referent']) && $files_copied++;
1363: }
1364: continue;
1365: } else if (file_exists($newfile)) {
1366: if (!$force) {
1367: warn('cannot overwrite `' . $this->unmake_path($newfile) . ' ' .
1368: $dest_parent . ' ' . $dest_path . ' ' . $local_file . "'");
1369: $files_copied = 0;
1370: continue;
1371: }
1372: if (fileinode($newfile) === fileinode($file)) {
1373: warn('%s is same file - skipping', $this->unmake_path($newfile));
1374: $files_copied = 0;
1375: continue;
1376: }
1377: // permissions are already asserted by checking dest
1378: unlink($newfile);
1379: }
1380: #fwrite(STDERR, "Copy $file to $newfile\n");
1381: copy($file, $newfile) && chown($newfile, $this->user_id) &&
1382: chgrp($newfile, $this->group_id) and $files_copied++;
1383: clearstatcache(true, $newfile);
1384: continue;
1385: }
1386:
1387: if (!$recursive) {
1388: warn("skipping directory `$local_file");
1389: $files_copied = 0;
1390: continue;
1391: }
1392:
1393: // copy directory contents
1394: $mkdir = '';
1395: $newdest = $dest . ($file[-1] === '/' ? '' : ('/' . basename($local_file)));
1396: // directory rename
1397: if (!file_exists($dest_path)) {
1398: $mkdir = 1;
1399: $newdest = $dest;
1400: // directory parenting
1401: } else if (!file_exists($dest_path) . '/' . basename($local_file)) {
1402: $mkdir = 1;
1403: }
1404: //print "Dir - ".$dest_path." -- ".$newdest." -- ".$mkdir."\n";
1405: if ($mkdir && !$this->create_directory($newdest, $fstat['permissions'], false)) {
1406: continue;
1407: }
1408:
1409: //fwrite(STDERR, "Enumerating dir $local_file to $newdest\n");
1410: $subreq = $this->copy(
1411: array($local_file . '/'),
1412: $newdest,
1413: $force,
1414: $recursive,
1415: $prune,
1416: $depth + 1
1417: );
1418: if ($prune && !$subreq) {
1419: $this->delete($dest, true);
1420: }
1421: $files_copied += $subreq;
1422: }
1423: }
1424:
1425: return (bool)$files_copied;
1426: }
1427:
1428: /**
1429: * Strip shadow prefix from path
1430: *
1431: * Because this is a wrapper to unmake_path,
1432: *
1433: * @param $path
1434: * @return string
1435: */
1436: public function unmake_shadow_path($path)
1437: {
1438: $shadow = $this->domain_shadow_path();
1439:
1440: if (0 === strpos($path, $shadow)) {
1441: $fst = $this->domain_fs_path();
1442: $path = $fst . substr($path, strlen($shadow));
1443: }
1444:
1445: return $path = $this->unmake_path($path);
1446: }
1447:
1448: /**
1449: * bool delete (mixed, [bool = FALSE])
1450: * Deletes a file from within the filesystem and calls {@see can_delete()}
1451: *
1452: * @param mixed $file accepts file or array of files to delete
1453: * @param bool $recursive recusrively delete files
1454: * @return bool|FileError
1455: */
1456: public function delete($file, $recursive = false)
1457: {
1458: if (!is_array($file)) {
1459: $file = array($file);
1460: }
1461:
1462: $data = $this->query('file_delete_backend', $file, (bool)$recursive);
1463: $this->_purgeCache($file);
1464:
1465: if (is_array($data)) {
1466: throw new FileError(implode("\n", $data));
1467: }
1468:
1469: return $data;
1470: }
1471:
1472: /**
1473: * bool delete_backend (mixed, [bool = FALSE])
1474: *
1475: * @param array $files files to remove
1476: * @param bool $recurse recurse into directories
1477: * @param int $depth current depth
1478: * @return bool
1479: *
1480: * @see delete()
1481: */
1482: public function delete_backend(array $files, bool $recurse, int $depth = 1)
1483: {
1484: // @var int return value
1485: $ret = 1;
1486: // @var int
1487: $ok = 1;
1488: // @var int length to truncate
1489: $truncate = 0;
1490: $optimized = $this->_optimizedShadowAssertion &&
1491: ($this->permission_level & PRIVILEGE_SITE);
1492:
1493: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
1494: $truncate = strlen($this->domain_fs_path());
1495: }
1496: $shadow = $optimized ? $this->domain_shadow_path() : null;
1497: $pathChecks = [];
1498: foreach ($files as $wcfile) {
1499: // at least something under /etc
1500: if (!isset($wcfile[5])) {
1501: \Error_Reporter::report('Critical file error - IN:' . var_export($files, true) .
1502: "\n\nOUT:" . var_export($wcfile, true));
1503: fatal("Something's wrong, aborting! ");
1504: }
1505: $link = '';
1506: $exdir = $this->make_path($wcfile, $link);
1507:
1508: if ($link) {
1509: // do not follow symlinks
1510: $exdir = $link;
1511: }
1512:
1513: if (!$exdir) {
1514: continue;
1515: } else if ($depth > 1 || \Util_PHP::is_link($exdir)) {
1516: // prevent glob("foo" -> ../../tmp);
1517: $globmatch = array($exdir);
1518: } else {
1519: $globmatch = glob($exdir, GLOB_NOSORT);
1520: }
1521:
1522: for ($i = 0, $n = count($globmatch); $i < $n;
1523: $i++, $ret &= $ok) {
1524: $ok = 0;
1525: $rmpath = $chkpath = $globmatch[$i];
1526: $file = $rmpath;
1527:
1528: // local file name
1529: if ($truncate) {
1530: $file = substr($globmatch[$i], $truncate);
1531: }
1532: // check file on shadow instead of fst/
1533: if ($optimized) {
1534: $chkpath = $shadow . $file;
1535: }
1536:
1537: $is_link = \Util_PHP::is_link($chkpath);
1538: // file outside truncate path or chkpath does not exist/is not link
1539: if (!$file || (!file_exists($chkpath) && !$is_link)) {
1540: $ok = 1;
1541: continue;
1542: }
1543: $parent = dirname($file);
1544: if (!isset($pathChecks[$parent])) {
1545: // perform stat only on secondary users or old platforms
1546: $stat = $this->stat_backend($parent);
1547: if ($stat instanceof Exception) {
1548: \Error_Reporter::handle_exception($stat);
1549: error($file . ': cannot delete- stat failed');
1550: continue;
1551: }
1552: /**
1553: * let OSA obliterate symlinks if they want to on their layer, only do stat check on non-OSA
1554: */
1555: $pathChecks[$parent] = $this->hasAccessRights($this->make_path($parent), POSIX_W_OK);
1556: }
1557:
1558: if (!$pathChecks[$parent]) {
1559: warn($file . ': cannot remove - permission denied');
1560: continue;
1561: }
1562:
1563: $is_dir = !$is_link && is_dir($chkpath);
1564: // file is a directory, enumerate its children, delete,
1565: // and if successful (directory is non-empty), remove this
1566: // directory
1567: if (!$is_link && $is_dir) {
1568: if (!$recurse) {
1569: // each dirent as a directory contains 1 additional hardlink to parent
1570: // @TODO assertion invalid for btrfs
1571: // https://linux-btrfs.vger.kernel.narkive.com/oAoDX89D/btrfs-st-nlink-for-directories
1572: if (!($ok = stat($rmpath)['nlink'] === 2 && rmdir($rmpath))) {
1573: warn($file . ': cannot remove directory without ' .
1574: 'recursive option');
1575: }
1576: continue;
1577: }
1578: clearstatcache(true, $rmpath);
1579: $dh = opendir($rmpath);
1580: if (!$dh) {
1581: error($file . ': cannot open directory');
1582: continue;
1583: }
1584:
1585: $dirfiles = array();
1586: while (false !== ($dirent = readdir($dh))) {
1587: if ($dirent === '.' || $dirent === '..') {
1588: continue;
1589: }
1590: $tmp = "${rmpath}/${dirent}";
1591: if (\is_link($tmp) || \is_file($tmp)) {
1592: unlink($tmp);
1593: clearstatcache(true, $tmp);
1594: } else {
1595: $dirfiles[] = "${file}/${dirent}";
1596: }
1597: }
1598: closedir($dh);
1599: $ok = $this->delete_backend($dirfiles, $recurse, $depth + 1);
1600: // cannot remove directory if subdir has files
1601: if (!$ok) {
1602: continue;
1603: }
1604: }
1605:
1606: if ((($is_link || !$is_dir) && !unlink($rmpath)) ||
1607: ($is_dir === true && !rmdir($rmpath))
1608: ) {
1609: $errmsg = Error_Reporter::get_last_php_msg();
1610: if ($errmsg) {
1611: warn('%s: cannot remove- %s', $file, $errmsg);
1612: }
1613: continue;
1614: }
1615:
1616: $ok = 1;
1617: }
1618:
1619: // ensure translated path is cleaned up
1620: $this->trans_paths[$this->site_id][$wcfile] = null;
1621: }
1622:
1623: if ($depth === 1) {
1624: $this->purge(false);
1625: }
1626: return (bool)($ret & $ok);
1627:
1628: }
1629:
1630: /**
1631: * bool chown(string, string[, bool = false])
1632: *
1633: * @param mixed $mFile array of filenames or single filename
1634: * @param int|string $mUser target uid or username
1635: * @param bool $mRecursive recursively chown
1636: * @return bool
1637: */
1638: public function chown($mFile, $mUser, $mRecursive = false)
1639: {
1640: if (!IS_CLI) {
1641: $ret = $this->query('file_chown', $mFile, $mUser, $mRecursive);
1642: $this->_purgeCache($mFile);
1643: $this->purge(false);
1644:
1645: return $ret;
1646: }
1647: return $this->chown_backend($mFile, $mUser, (bool)$mRecursive, 0);
1648: }
1649:
1650: /**
1651: * chown recursive helper
1652: *
1653: * Flushes mount cache after transformation
1654: *
1655: * @param $files
1656: * @param $user
1657: * @param bool $recursive
1658: * @param int $depth
1659: * @return bool
1660: * @throws FileError
1661: */
1662: private function chown_backend($files, $user, bool $recursive, int $depth): bool
1663: {
1664: $validUsers = array_keys($this->user_get_users());
1665: $validUsers[] = \Web_Module::WEB_USERNAME;
1666:
1667: if ($this->tomcat_enabled()) {
1668: $validUsers[] = $this->tomcat_system_user();
1669: }
1670: if (is_int($user)) {
1671: $user = $this->user_get_username_from_uid($user);
1672: }
1673: if (!in_array($user, $validUsers, true)) {
1674: return error('invalid user `' . $user . "'");
1675: }
1676:
1677: if (!is_array($files)) {
1678: $files = array($files);
1679: }
1680: $errors = array();
1681: $tUID = $this->user_get_users();
1682:
1683: // RHEL constant UID
1684: $tUID[\Web_Module::WEB_USERNAME] = array('uid' => APACHE_UID);
1685: if (!isset($tUID[$user]['uid'])) {
1686: return error('Eep, unable to find UID for ' . $user);
1687: }
1688: $tUID = (int)$tUID[$user]['uid'];
1689:
1690: foreach ($files as $file) {
1691: $link = null;
1692: $path = $this->make_shadow_path($file, $link);
1693: // OSA on link, resolve path
1694: if ($link) {
1695: $path = $this->make_path($file);
1696: $stat = $this->stat_backend($file, false);
1697:
1698: if ($path instanceof Exception) {
1699: $errors[$file] = $path->getMessage();
1700: continue;
1701: }
1702: if ($stat instanceof Exception) {
1703: $errors[$file] = $stat->getMessage();
1704: continue;
1705: }
1706: if (!$this->hasAccessRights(dirname($path))) {
1707: $errors[$file] = 'insufficient permissions to access';
1708: continue;
1709: }
1710: if (!$link && !$stat['can_chown']) {
1711: $errors[$file] = 'Unable to change ownership of ' . $file;
1712: continue;
1713: }
1714: }
1715:
1716: if (!$link && $recursive && is_dir($path)) {
1717: // Recursive chown
1718: $files = \Opcenter\Filesystem::readdir($path, static function ($item) use ($file) {
1719: return "$file/$item";
1720: });
1721: if ($files === false) {
1722: $errors[$file] = 'failed to open directory';
1723: continue;
1724: }
1725: $status = $this->chown_backend($files, $user, $recursive, $depth + 1);
1726: if ($status instanceof Exception) {
1727: $errors[$file] = $status->getMessage();
1728: }
1729: } else if ($link) {
1730: // symlink
1731: warn('%s is a link, treating as symlink', $file);
1732: if (!$this->chown_symlink($file, $user)) {
1733: $errors[$file] = \Error_Reporter::get_last_php_msg();
1734: }
1735: }
1736:
1737: if (!$link && !chown($path, $tUID)) {
1738: // regular file
1739: $errors[$file] = Error_Reporter::get_last_php_msg();
1740: }
1741: }
1742: $this->_purgeCache($files);
1743:
1744: if (count($errors)) {
1745: throw new FileError(implode("\n", $errors));
1746: }
1747:
1748: if ($depth === 0) {
1749: $this->purge();
1750: }
1751:
1752: return true;
1753: }
1754:
1755: /**
1756: * Dump OverlayFS cache
1757: *
1758: * @return bool
1759: */
1760: public function purge(bool $full = true)
1761: {
1762: if ($this->permission_level & ~(PRIVILEGE_SITE | PRIVILEGE_USER)) {
1763: return true;
1764: }
1765:
1766: if (!IS_CLI) {
1767: $this->stat_cache[$this->site_id] = [];
1768: return $this->query('file_purge', $full);
1769: }
1770:
1771: if (!$full) {
1772: return true;
1773: }
1774:
1775: return (new \Opcenter\Service\ServiceLayer($this->site))->flush();
1776: }
1777:
1778: /**
1779: * bool chgrp(string, string[, bool = false])
1780: *
1781: * @param string $mFile filename
1782: * @param string $mGroup target gid, effectively just the admin's uid
1783: * @param bool $mRecursive recursively chown
1784: * @return bool
1785: */
1786: public function chgrp($mFile, $mGroup, $mRecursive = false)
1787: {
1788: // @XXX why is this even here?
1789: if (!IS_CLI) {
1790: return $this->query('file_chgrp', $mFile, $mGroup, $mRecursive);
1791: }
1792: $admin = $this->group_id;
1793: foreach ($this->common_get_users() as $user => $data) {
1794: if ($data['gid'] == $data['uid']) {
1795: $admin = $user;
1796: }
1797: }
1798: if ($mGroup != $admin) {
1799: return error('invalid group `' . $mGroup . "'");
1800: } else if (!is_array($mFile)) {
1801: $mFile = array($mFile);
1802: }
1803: $errors = array();
1804:
1805: if ($this->permission_level & PRIVILEGE_SITE) {
1806: $optimized = $this->_optimizedShadowAssertion;
1807: } else {
1808: $optimized = false;
1809: }
1810: foreach ((array)$mFile as $file) {
1811: if ($optimized) {
1812: $path = $this->make_shadow_path($file);
1813: } else {
1814: $path = $this->make_path($file);
1815: $stat = $this->stat_backend($file, false);
1816: if ($path instanceof Exception) {
1817: $errors[$file] = $path->getMessage();
1818: continue;
1819: } else if ($stat instanceof Exception) {
1820: $errors[$file] = $stat->getMessage();
1821: continue;
1822: } else if (!$this->hasAccessRights(dirname($path))) {
1823: $errors[$file] = 'insufficient permissions to access';
1824: continue;
1825: } else if (!$stat['can_chgrp']) {
1826: $errors[$file] = 'Unable to change group ownership of ' . $file;
1827: continue;
1828: }
1829: }
1830:
1831: if ($mRecursive && is_dir($path)) {
1832: // Recursive chown
1833: $files = \Opcenter\Filesystem::readdir($path, static function ($item) use ($file) {
1834: return "$file/$item";
1835: });
1836: if ($files === false) {
1837: $errors[$file] = 'failed to open directory';
1838: continue;
1839: }
1840: $status = $this->chgrp($files, $mGroup, $mRecursive);
1841: if ($status instanceof Exception) {
1842: $errors[$file] = $status->getMessage();
1843: continue;
1844: }
1845: }
1846: if (is_link($path)) {
1847: warn('File %s is not regular file, treating as symlink', $file);
1848: \Util_PHP::lchgrp($path, $mGroup);
1849: } else if (!chgrp($path, $mGroup)) {
1850: $errors[$file] = Error_Reporter::get_last_php_msg();
1851: }
1852: }
1853:
1854: return (sizeof($errors) == 0 ? true : $errors);
1855: }
1856:
1857: /**
1858: * bool chmod (string, int)
1859: *
1860: * @param string $mFile file name
1861: * @param int $mMode mode in octal for the file
1862: * @param bool $mRecursive
1863: */
1864: public function chmod($mFile, $mMode, $mRecursive = false): bool
1865: {
1866: if (is_string($mMode) && !ctype_digit($mMode)) {
1867: return error('invalid mode');
1868: }
1869:
1870: $ret = $this->query('file_chmod_backend', $mFile, $mMode, $mRecursive);
1871: $this->_purgeCache($mFile);
1872:
1873: return $ret;
1874: }
1875:
1876: /**
1877: * bool chmod_backend (string, int[, bool = false])
1878: * {@link chmod}
1879: */
1880: public function chmod_backend($mFile, $mMode, $mRecursive)
1881: {
1882: if (!is_int($mMode) && (strlen((string)$mMode) != 4)) {
1883: $mMode = (float)octdec('0' . (string)$mMode);
1884: } else if (!is_float($mMode)) {
1885: $mMode = (float)octdec((string)$mMode);
1886: }
1887: $mMode = (int)$mMode;
1888: /* 4095 dec -> 7777 oct
1889: * 0140000 -> socket
1890: * 0147777 -> socket + all perms
1891: */
1892: if ($mMode > 0xCFFF) {
1893: // 0147777
1894: return error("invalid mode `%o'", $mMode);
1895: }
1896: $purge = (array)$mFile;
1897: $path = $this->make_path($mFile, $link);
1898: if ($path instanceof Exception) {
1899: return $path;
1900: } else if ($link) {
1901: $newpath = $this->unmake_path($path);
1902: if ($newpath === $mFile) {
1903: return error("`%s': irresolvable symlink", $newpath);
1904: }
1905: return $this->chmod_backend($newpath, $mMode, $mRecursive);
1906: }
1907: if ($mRecursive && is_dir($path)) {
1908:
1909: $files = \Opcenter\Filesystem::readdir($path);
1910: if ($files === false) {
1911: return false;
1912: }
1913:
1914: foreach ($files as $file) {
1915: $file = $mFile . '/' . $file;
1916: $stat = $this->stat_backend($file, false);
1917: if ($stat['link']) {
1918: continue;
1919: }
1920: if ($stat instanceof Exception) {
1921: error($stat->getMessage());
1922: continue;
1923: }
1924: if (!$stat['can_chown']) {
1925: warn('cannot chmod perm denied: ' . $file);
1926: continue;
1927: }
1928: $purge[] = $file;
1929:
1930: if ($stat['file_type'] == '\dir') {
1931: $this->chmod_backend($file, $mMode, $mRecursive);
1932: } else {
1933: chmod($this->make_path($file), $mMode);
1934: }
1935: }
1936: }
1937: $stat = $this->stat_backend($mFile, false);
1938: if ($stat instanceof Exception) {
1939: return warn($stat->getMessage());
1940: }
1941: if (!$stat['can_chown']) {
1942: return warn('cannot chmod perm denied: ' . $mFile);
1943: }
1944:
1945: $ret = chmod($path, (int)$mMode);
1946: $this->_purgeCache($purge);
1947:
1948: return $ret;
1949: }
1950:
1951: /**
1952: * Determines the MIME type of a file through the file shell command
1953: *
1954: * @param string $file
1955: * @return string|null mime type
1956: *
1957: */
1958: public function get_mime_type($file): ?string
1959: {
1960: $path = $this->make_path($file);
1961: if (!IS_CLI) {
1962: if (!$path || ($path instanceof Exception) || !file_exists($path) || !is_readable($path)) {
1963: return $this->query('file_get_mime_type', $file);
1964: }
1965:
1966: return mime_content_type($path) ?: null;
1967: }
1968:
1969: $stat = $this->stat($file);
1970: if ((!$stat || !$stat['can_read']) || ($stat['link'] && null === $stat['referent'])) {
1971: return null;
1972: }
1973:
1974: return mime_content_type($path) ?: null;
1975: }
1976:
1977: /**
1978: * Get file contents
1979: *
1980: * @param string $mPath path to the filename
1981: * @param bool $raw base64 encode data
1982: * @return string
1983: *
1984: */
1985: public function get_file_contents($mPath, $raw = true)
1986: {
1987: $path = $this->make_path($mPath);
1988:
1989: if ($path instanceof Exception) {
1990: return $path;
1991: }
1992:
1993: if (!is_readable($path)) {
1994: return $this->query('file_get_file_contents_backend', $mPath, $raw);
1995: }
1996:
1997: if (!is_file($path)) {
1998: return error($mPath . ' is not a file');
1999: }
2000:
2001: return !$raw ? base64_encode(file_get_contents($path)) : file_get_contents($path);
2002:
2003:
2004: }
2005: /* }}} */
2006:
2007: /**
2008: * @see get_file_contents()
2009: * @param string $mPath file name
2010: * @param bool $mRaw
2011: * @return string base64-encoded file
2012: */
2013: public function get_file_contents_backend($mPath, $mRaw = true)
2014: {
2015: $path = $this->make_path($mPath);
2016: if ($path instanceof Exception) {
2017: return $path;
2018: } else if (!is_file($path)) {
2019: return new FileError($mPath . ' is not a file');
2020: }
2021: if (!is_readable($path)) {
2022: return error('Unable to read ' . $mPath);
2023: }
2024:
2025: if (!$this->hasAccessRights($path)) {
2026: return error('Unable to read file');
2027: }
2028:
2029: $str = file_get_contents($path);
2030:
2031: return ($mRaw ? $str : base64_encode($str));
2032:
2033: }
2034:
2035: /**
2036: * Write contents to file
2037: *
2038: * @param string $path path to the filename
2039: * @param string $data file data
2040: * @param bool $overwrite if the file exists, overwrite
2041: * @param bool $binary data is to be treated as binary data. Data must be base64 encoded
2042: * @return bool
2043: *
2044: * @privilege PRIVILEGE_ALL
2045: */
2046: public function put_file_contents($file, $data, $overwrite = true, $binary = false)
2047: {
2048: return $this->query('file_put_file_contents_backend', $file, $data, (bool)$overwrite, (bool)$binary);
2049: }
2050:
2051: /**
2052: * @see put_file_contents()
2053: */
2054: public function put_file_contents_backend($mFile, $mData, $mOverwrite, $binary)
2055: {
2056: $path = $this->make_path($mFile);
2057: if ($path instanceof Exception) {
2058: return $path;
2059: }
2060: $dir_stat = $this->stat_backend(dirname($mFile), false);
2061:
2062: if ($dir_stat instanceof Exception) {
2063: return $dir_stat;
2064: }
2065:
2066: if (file_exists($path)) {
2067: $file_stat = $this->stat_backend($mFile, false);
2068: if ($file_stat instanceof Exception) {
2069: return $file_stat;
2070: }
2071: }
2072:
2073: if (!file_exists($path) && !$this->hasAccessRights(dirname($path), POSIX_W_OK)) {
2074: return error('Cannot write to destination directory ' . dirname($mFile));
2075: }
2076:
2077: if ($binary && !preg_match('/^[a-zA-Z0-9+\/=]*$/D', $mData)) {
2078: return new ArgumentError('File data not base64 encoded');
2079: }
2080:
2081: if (file_exists($path)) {
2082: if (!$mOverwrite) {
2083: return new FileError('Target ' . $mFile . ' already exists');
2084: } else {
2085: if ($mOverwrite && !is_file($path)) {
2086: return new FileError('Target ' . $mFile . ' is not a file');
2087: } else {
2088: if (!$file_stat['can_write']) {
2089: return error('Cannot overwrite file');
2090: }
2091: }
2092: }
2093: }
2094:
2095: if (!file_exists($path) &&
2096: ($status = $this->create_file($mFile, 0644)) instanceof Exception
2097: ) {
2098: return $status;
2099: }
2100:
2101: if (!$fp = fopen($path, 'w' . ($binary ? '' : 'b'))) {
2102: return error("Failed to open `%s'", $mFile);
2103: }
2104: fwrite($fp, !$binary ? $mData : base64_decode($mData));
2105: fclose($fp);
2106: $this->_purgeCache((array)$mFile);
2107:
2108: return true;
2109: }
2110:
2111: /**
2112: * Create an empty file
2113: *
2114: * @param string $file file name to create
2115: * @param int $mode mode for the file
2116: * @return bool|\Exception
2117: */
2118: public function create_file(string $file, $mode = 0644)
2119: {
2120: if (!IS_CLI) {
2121: return $this->query('file_create_file', $file, $mode);
2122: }
2123:
2124: $path = $this->make_path($file);
2125: if ($path instanceof Exception) {
2126: return $path;
2127: }
2128: $stat = $this->stat_backend(dirname($file));
2129:
2130: if ($stat instanceof Exception || !$stat) {
2131: return $stat;
2132: }
2133:
2134: if (!$stat['can_write']) {
2135: return error(dirname($file) . ': cannot write to directory');
2136: }
2137: if (file_exists($path)) {
2138: return error($file . ': file exists');
2139: } else if (is_link($path)) {
2140: return error($file . ': is link');
2141: }
2142: $fp = fopen($path, 'w');
2143: fclose($fp);
2144: chown($path, (int)$this->user_id);
2145: chgrp($path, (int)$this->group_id);
2146: chmod($path, $mode);
2147:
2148: return true;
2149: }
2150:
2151: /**
2152: * array get_directory_contents (string)
2153: *
2154: * @param string $mPath the path to the directory
2155: * @return array
2156: */
2157: public function get_directory_contents($mPath, $sort = true)
2158: {
2159: return $this->query('file_get_directory_contents_backend', rtrim($mPath, '/'), $sort, true);
2160: }
2161:
2162: public function get_directory_contents_backend($mPath, $sort = true, $shadow = false)
2163: {
2164: $path = $shadow ? $this->make_shadow_path($mPath) : $this->make_path($mPath);
2165: if ($path instanceof Exception) {
2166: return $path;
2167: }
2168: if (!is_dir($path)) {
2169: return error("`%s': invalid directory", $mPath);
2170: }
2171: // trust transformed path, e.g. get_directory_contents("~/")
2172: $mPath = rtrim($shadow ? $this->unmake_shadow_path($path) : $this->unmake_path($path), '/');
2173: $stat = $this->stat_backend($this->unmake_shadow_path($path), false);
2174:
2175: if (!$this->hasAccessRights($path)) {
2176: return error("cannot access directory `%s' permission denied",
2177: $mPath);
2178: }
2179:
2180: if ($stat['link']) {
2181: $mPath = $stat['referent'];
2182: }
2183: $dirHandle = dir($path);
2184: if (!$dirHandle) {
2185: return error(__FUNCTION__ . '(): unable to access directory');
2186: }
2187: $files = array();
2188:
2189: while (false !== ($entry = $dirHandle->read())) {
2190: /** chroot'd passwd file */
2191: if ($entry == '.' || $entry == '..') {
2192: continue;
2193: }
2194:
2195: $stat = $this->stat($mPath . '/' . $entry);
2196: if ($stat instanceof Exception) {
2197: return $stat;
2198: }
2199: if (!isset($stat['owner'])) {
2200: /**
2201: * dir() suffers from a delayed cache response
2202: * confirm stat finds the file on the fs
2203: */
2204: continue;
2205: }
2206: $stat['file_name'] = $mPath . '/' . $entry;
2207:
2208: if ($sort) {
2209: $files[] = $stat;
2210: } else {
2211: $files[$mPath . '/' . $entry] = $stat;
2212: }
2213: }
2214: unset($dirHandle);
2215:
2216: if ($sort) {
2217: Util_Conf::sort_files($files);
2218: } else {
2219: Util_Conf::sort_files($files, 'key');
2220: }
2221:
2222: return $files;
2223: }
2224:
2225: /**
2226: * Make directories writeable by Web server for use with DAV
2227: *
2228: * @param string|array $paths paths
2229: * @param bool $recursive recursively change ownerhsip
2230: *
2231: * Calls setgid, fixes permissions for Apache-written directories
2232: *
2233: * @return bool
2234: */
2235: public function fix_apache_perms_backend($paths, $recursive = false)
2236: {
2237: if (!is_array($paths)) {
2238: $paths = array($paths);
2239: }
2240: $prefix = $this->domain_fs_path();
2241: if (version_compare(platform_version(), '4.5', '>=')) {
2242: $prefix = $this->domain_shadow_path();
2243: }
2244:
2245: foreach ($paths as $path) {
2246: $path_resolved = $prefix . '/' . $path;
2247: if (!file_exists($path_resolved)) {
2248: error("`$path': invalid path");
2249: continue;
2250: }
2251: $stat = $this->file_stat($path);
2252: $uid = $stat['uid'];
2253:
2254: chgrp($path_resolved, (int)$this->group_id);
2255: $safe_path = escapeshellarg($path_resolved);
2256:
2257: // chown path to apache:gid
2258: Util_Process::exec('chown -h%s %s:%s %s',
2259: ($recursive ? 'R' : ''),
2260: \Web_Module::WEB_USERNAME,
2261: $this->group_id,
2262: $safe_path
2263: );
2264:
2265: // set ACLs on existing files
2266: $limit = !$recursive ? '-maxdepth 0' : '';
2267: $def_cmd = ' -d -m user:%5$s:%2$s -d -m user:%4$s:%2$s';
2268: $cmd = 'chmod u=+%2$s,g=+%3$s "{}" ; ' .
2269: 'setfacl -m user:%4$s:%2$s -m user:%5$s:%2$s';
2270: Util_Process::exec('find %1$s ' . $limit . ' -type d -print0 | ' .
2271: 'xargs -0 -i /bin/sh -c \'' . $cmd . $def_cmd . ' "{}"\'',
2272: $safe_path,
2273: 'rwx',
2274: 'rwxs',
2275: $uid,
2276: \Web_Module::WEB_USERNAME
2277:
2278: );
2279: $status = Util_Process::exec('find %1$s ' . $limit . ' -type f -print0 | ' .
2280: 'xargs -0 -i /bin/sh -c \'' . $cmd . ' "{}"\'',
2281: $safe_path,
2282: 'rw',
2283: 'rw',
2284: $uid,
2285: \Web_Module::WEB_USERNAME
2286: );
2287: }
2288:
2289: return $status['success'];
2290: }
2291:
2292: /**
2293: * Locate files under a given path matching requirements
2294: *
2295: * Requirements:
2296: * - user: username
2297: * - perm: permissions
2298: * - mtime/ctime: modification/creation time.
2299: * n > 0, more than n days ago, n < 0, less than n days ago
2300: * - name/regex: filename glob/regex match
2301: *
2302: * @param string $path
2303: * @param array $requirements optional requirements
2304: * @param bool $union all must match
2305: * @return array|bool false on error
2306: */
2307: public function audit(string $path, array $requirements = [], bool $union = true)
2308: {
2309: if (!IS_CLI) {
2310: return $this->query('file_audit', $path, $requirements, $union);
2311: }
2312: // @TODO convert to Opcenter class, nasty
2313: if (!$requirements) {
2314: $webuser = $this->web_get_user($path);
2315: $requirements = ['user' => $webuser];
2316: }
2317: $recognized = ['user', 'perm', 'mtime', 'ctime', 'regex', 'name'];
2318: if ($bad = array_except($requirements, $recognized)) {
2319: return error("Unrecognized audit options: `%s'", implode(',', $bad));
2320: }
2321: if (!$fspath = $this->make_shadow_path($path)) {
2322: return error("unknown path `%s'", $path);
2323: }
2324: if (!$stat = $this->stat_backend($path)) {
2325: return error("failed to stat `%s'", $path);
2326: }
2327: if (!$stat['file_type'] === 'dir' || !$stat['can_execute']) {
2328: return error("path `%s' is not a directory or cannot access", $path);
2329: }
2330: $cmdstr = 'find %(path)s';
2331: $cmds = [];
2332: $cmdargs = ['path' => $fspath];
2333:
2334: if (isset($requirements['perm'])) {
2335: $cmds[] = '-perm %(perm)s';
2336: if (($idx = strspn((string)$requirements['perm'],
2337: '012345678gwox+-r')) !== \strlen((string)$requirements['perm'])) {
2338: return error("Permissions must be in octal or symbolic. Invalid characters found pos %d: `%s'",
2339: $idx,
2340: substr((string)$requirements['perm'], $idx)
2341: );
2342: }
2343: $cmdargs['perm'] = (string)$requirements['perm'];
2344: }
2345: if (isset($requirements['user'])) {
2346: $cmds[] = '-user %(user)s';
2347: if ($requirements['user'][0] === '&' || $requirements['user'][0] === '|') {
2348: $cmdstr .= ' -o ';
2349: $requirements['user'] = substr($requirements['user'], 1);
2350: }
2351: if (!$this->user_exists($requirements['user']) && !\array_key_exists($requirements['user'], $this->permittedUsers())) {
2352: return error("Unknown user `%s'", $requirements['user']);
2353: }
2354: $cmdargs['user'] = $requirements['user'];
2355: }
2356: foreach (['ctime', 'mtime'] as $spec) {
2357: if (!isset($requirements[$spec])) {
2358: continue;
2359: }
2360: // @todo deep type conversion in SOAP
2361: if ((int)$requirements[$spec] != $requirements[$spec]) {
2362: return error("%s must be numeric, got `%s'", $spec, $requirements[$spec]);
2363: }
2364:
2365: $cmds[] = "-${spec} %(${spec})d";
2366: $cmdargs[$spec] = $requirements[$spec];
2367: }
2368: if (isset($requirements['name'], $requirements['regex'])) {
2369: return error('Both name and regex cannot be specified');
2370: }
2371: foreach (['name', 'regex'] as $spec) {
2372: if (!isset($requirements[$spec])) {
2373: continue;
2374: }
2375: $cmds[] = "-${spec} %(${spec})s";
2376: $cmdargs[$spec] = $requirements[$spec];
2377: break;
2378: }
2379:
2380: $ret = \Util_Process_Safe::exec($cmdstr . ' \( ' . implode($union ? ' ' : ' -o ',
2381: $cmds) . ' \) -printf "%%P\n"', $cmdargs);
2382: if (!$ret['success']) {
2383: return error("failed to locate files under `%s': %s", $path, $ret['stderr']);
2384: }
2385:
2386: return !$ret['stdout'] ? [] : explode("\n", rtrim($ret['stdout']));
2387: }
2388:
2389: /**
2390: * Create a map of permitted users + system id
2391: *
2392: * @return array
2393: */
2394: private function permittedUsers(): array
2395: {
2396: // don't worry about caching, already done in user_get_users
2397: $uuidmap = [
2398: \Web_Module::WEB_USERNAME => posix_getpwnam(\Web_Module::WEB_USERNAME)['uid']
2399: ];
2400:
2401: if ($this->tomcat_permitted()) {
2402: $tcuser = $this->tomcat_system_user();
2403: $uuidmap[$tcuser] = posix_getpwnam($tcuser)['uid'];
2404: }
2405: $users = $this->user_get_users();
2406:
2407: return array_merge(array_combine(array_keys($users), array_column($users, 'uid')), $uuidmap);
2408: }
2409:
2410: /**
2411: * array report_quota (mixed)
2412: *
2413: * @param $mUIDs array of uids
2414: * @privilege PRIVILEGE_SITE
2415: * @return array associative array of quotas for given users supplied by the
2416: * parameter $mUIDs. The array is structured as follows:
2417: * -uid: user id of the user N.B.: the username is not used; instead
2418: * call {@link Site_Module::get_users} to retrieve the numeric values
2419: * - quota:
2420: * - soft:
2421: * - hard
2422: * - files:
2423: * - file_soft:
2424: * - file_hard
2425: */
2426: public function report_quota($mUIDs)
2427: {
2428: deprecated_func('use user_get_quota()');
2429:
2430: return null;
2431: }
2432:
2433: /**
2434: * Convert end-of-line characters
2435: *
2436: * @param string $mFile filename
2437: * @param string $mTarget target platform
2438: * @return bool
2439: */
2440: public function convert_eol($mFile, $mTarget)
2441: {
2442: if (!IS_CLI) {
2443: return $this->query('file_convert_eol', $mFile, $mTarget);
2444: }
2445: $mTarget = strtolower($mTarget);
2446: if (!in_array($mTarget, array('unix', 'windows', 'mac'))) {
2447: return error('unknown platform `' . $mTarget . "'");
2448: }
2449: $stat = $this->stat_backend($mFile);
2450: if (!$stat['can_read'] || !$stat['can_write']) {
2451: return error('cannot access `' . $mFile . "'");
2452: }
2453: $file = $this->make_path($mFile);
2454:
2455: $cmd = 'dos2unix';
2456: if ($mTarget == 'windows') {
2457: $cmd = 'unix2dos';
2458: } else if ($mTarget == 'mac') {
2459: $cmd = 'dos2unix -c mac';
2460: }
2461:
2462: return Util_Process_Safe::exec($cmd . ' %s',
2463: $file) && chown($file, $stat['uid'])
2464: && chgrp($file, $stat['gid']);
2465: }
2466:
2467: /**
2468: * Rename a file
2469: *
2470: * @param string $file filename
2471: * @param string $newfile new filename
2472: * @return bool
2473: */
2474: public function rename($from, $to, $files = array())
2475: {
2476: if (!IS_CLI) {
2477: $res = $this->query('file_rename', $from,
2478: $to, $files);
2479: if ($res) {
2480: $this->_purgeCache([$from, $to]);
2481: }
2482:
2483: return $res;
2484: }
2485: if (!is_array($files) || !$files) {
2486: return $this->move($from, $to);
2487: }
2488:
2489: if (!is_array($from)) {
2490: $file = array($from);
2491: }
2492: if (!is_array($to)) {
2493: $newfile = array($to);
2494: }
2495: $nsrc = sizeof($file);
2496: $ndest = sizeof($newfile);
2497: if ($nsrc > 1 && $ndest != $nsrc) {
2498: if ($ndest != 1) {
2499: return error('cannot move files- destination ' .
2500: 'must be directory for multiple files');
2501: }
2502:
2503: }
2504: for ($i = 0, $n = sizeof($file); $i < $n; $i++) {
2505: if (sizeof($newfile) == 1) {
2506: $newfile[$i] = $newfile[0];
2507: }
2508: if ($newfile[$i][0] != '/') {
2509: $newfile[$i] = dirname($file[$i]) . '/' . $newfile[$i];
2510: }
2511: }
2512:
2513: $changed_ctr = 0;
2514:
2515: for ($i = 0, $iMax = sizeof($file); $i < $iMax; $i++) {
2516: $link = '';
2517: $src_path = $this->make_path($file[$i], $link);
2518: $src_stat = $this->stat_backend($file[$i]);
2519:
2520: $dest_path = $this->make_path($newfile[$i]);
2521: $dest_stat = $this->stat_backend(dirname($newfile[$i]));
2522:
2523: if ($dest_path instanceof Exception || $dest_stat instanceof Exception ||
2524: $src_path instanceof Exception || $src_stat instanceof Exception
2525: ) {
2526: if (file_exists($dest_path) || !$link && !file_exists($src_path)) {
2527: continue;
2528: }
2529: }
2530: // no perms
2531: if (!$link || !$dest_stat['can_execute'] && !$dest_stat['can_write']) {
2532: continue;
2533: }
2534:
2535: if ($src_stat['link']) {
2536: // rename won't rename a symbolic link; delete and recreate the link
2537: $this->delete(array($this->unmake_path($link)), false);
2538: $this->symlink($src_stat['referent'], $this->unmake_path($dest_path));
2539: $this->chown_symlink($this->unmake_path($dest_path), $src_stat['owner']) && $changed_ctr++;
2540: } else {
2541: rename($src_path, $dest_path) && $changed_ctr++;
2542: }
2543:
2544: }
2545:
2546: return $changed_ctr > 0;
2547: }
2548:
2549: /**
2550: * Move files
2551: *
2552: * @param mixed $src source file(s) or directories
2553: * @param string $dest destination directory
2554: * @param string $overwrite overwrite destination if exists
2555: * @return bool
2556: */
2557: public function move($src, $dest, $overwrite = false)
2558: {
2559: // @TODO algorithm is sloppy
2560: if (!IS_CLI) {
2561: $res = $this->query('file_move', $src, $dest, (bool)$overwrite);
2562: if ($res) {
2563: $this->_purgeCache($src);
2564: }
2565:
2566: return $res;
2567: }
2568:
2569: if (!$src || !$dest) {
2570: return error('missing source/destination');
2571: }
2572: if ($this->permission_level & PRIVILEGE_SITE) {
2573: $optimized = (bool)$this->_optimizedShadowAssertion;
2574: } else {
2575: $optimized = false;
2576: }
2577: $dest_path = $this->make_path($dest, $link);
2578: $dest_parent = dirname($dest_path);
2579: if (!$link) {
2580: // $dest can be /dest/dir/name or /dest/dir where $src is appended
2581: $tmp = $this->make_path(\dirname($dest), $link);
2582: if ($link) {
2583: $dest = $this->unmake_path($tmp) . basename($dest);
2584: }
2585: }
2586: if ($link) {
2587: $optimized = false;
2588: }
2589: if ($optimized && !$link) {
2590: // OverlayFS doesn't like direct operations on r/w branch
2591: // perform move onto synthetic fs
2592: $dest_parent = $this->make_shadow_path(dirname($dest));
2593: }
2594: $unmakeFn = $optimized ? 'unmake_shadow_path' : 'unmake_path';
2595:
2596: if (!file_exists($dest_parent)) {
2597: return error('move: destination directory `' . dirname($dest) . "' does not exist");
2598: } else if (!is_dir($dest_parent)) {
2599: return error('move: `' . dirname($dest) . "' is not a directory");
2600: } else if (!$optimized && !$this->hasAccessRights($dest_parent)) {
2601: return error('move: `' . dirname($dest) . "' cannot access - permission denied");
2602: }
2603:
2604: if (!is_array($src)) {
2605: if ($src[-1] === '/') {
2606: $stat = $this->file_stat($src);
2607: if ($stat && $stat['can_read'] && $stat['can_execute']) {
2608: $srcset = [];
2609: $files = scandir($this->domain_fs_path($src), SCANDIR_SORT_NONE);
2610: foreach ($files as $file) {
2611: if ($file === '..' || $file === '.') {
2612: continue;
2613: }
2614: $srcset[] = "${src}/${file}";
2615: }
2616: return $this->move($srcset, $dest, $overwrite);
2617: }
2618: }
2619:
2620: $src = array($src);
2621: }
2622:
2623: // straight file rename
2624: // mv /a.txt /b.txt
2625: if (!file_exists($dest_path)) {
2626: if (isset($src[1])) {
2627: return error('move: cannot rename multiple files to new file');
2628: }
2629: $parent = $this->{$unmakeFn}($dest_parent);
2630: } else {
2631: $parent = $this->unmake_path($dest_path);
2632: }
2633: if ($optimized) {
2634: $dest_pstat = [
2635: 'file_type' => filetype($dest_parent)
2636: ];
2637: } else {
2638: $dest_pstat = $this->stat_backend($parent);
2639: }
2640:
2641: // verify that dest is writeable
2642: if (!$optimized && (!$dest_pstat['can_write'] || !$dest_pstat['can_execute'])) {
2643: return error('move: `' . $parent . "' cannot write - permission denied");
2644: }
2645:
2646: // move all files
2647: $nchanged = -1;
2648: $perm_cache = array();
2649: // single item
2650: $isRename = !isset($src[1]);
2651: $destIsDir = $dest_pstat['file_type'] == 'dir';
2652:
2653: for ($i = 0, $nsrc = sizeof($src); $i < $nsrc; $i++) {
2654: $lchanged = $nchanged;
2655: $nchanged = 0;
2656: $link = '';
2657: $file = $src[$i];
2658: $src_path = $this->make_path($file, $link);
2659:
2660: if (!file_exists($src_path)) {
2661: warn('move: `' . $file . "': No such file or directory");
2662: continue;
2663: } else if ($src_path === $dest_path) {
2664: warn('move: `' . $file . "': source and dest are the same");
2665: continue;
2666: }
2667:
2668: if ($optimized) {
2669: $src_stat = array(
2670: 'file_type' => filetype($src_path),
2671: 'uid' => fileowner($src_path),
2672: 'link' => \Util_PHP::is_link($src_path)
2673: );
2674: } else {
2675: $src_stat = $this->stat_backend($file);
2676: }
2677:
2678:
2679: if (!$src_stat || $src_stat instanceof Exception) {
2680: if ($src_stat instanceof Exception) {
2681: warn('`' . $file . "': " . $src_stat->getMessage());
2682: }
2683: continue;
2684: }
2685:
2686: // source directory must have -wx permission
2687: $src_parent = dirname($src_path);
2688: if (!isset($perm_cache[$src_parent])) {
2689: $src_pstat = $this->stat_backend($this->unmake_path($src_parent));
2690: $perm_cache[$src_parent] = !$src_pstat instanceof Exception && $src_pstat &&
2691: $src_pstat['can_write'] && $src_pstat['can_execute'];
2692: }
2693:
2694: if (!$perm_cache[$src_parent]) {
2695: warn('cannot move `' . $file . "' - permission denied");
2696: continue;
2697: }
2698:
2699: $rename_dest = $dest_path;
2700: // add file/dirname onto destination if dest is exists/is dir
2701: if (!$isRename && $destIsDir) {
2702: // destination is directory
2703: $rename_dest = $dest_path . DIRECTORY_SEPARATOR . basename($file);
2704: } else if ($src_stat['file_type'] != 'dir' && file_exists($dest_path)) {
2705: $rename_dest = $dest_path;
2706: } else if ($src_stat['file_type'] == 'dir' && is_dir($dest_path)) {
2707: $rename_dest .= DIRECTORY_SEPARATOR . basename($file);
2708: }
2709:
2710:
2711: if (!$destIsDir && $src_stat['file_type'] == 'dir' &&
2712: $dest_pstat['file_type'] == 'file'
2713: ) {
2714: warn('cannot move `' . $file . "' - $dest is a file");
2715: continue;
2716: }
2717:
2718: // need to do an extra round of lookups to
2719: // ensure files/directories are not clobbered
2720: if (file_exists($rename_dest)) {
2721: if (!$overwrite) {
2722: warn('cannot move `' . basename($file) . "' - destination `" . basename($rename_dest) . "' exists");
2723: continue;
2724: }
2725: $del = $optimized ? unlink($rename_dest) : $this->delete($this->unmake_path($rename_dest), true);
2726: if (!$del || $del instanceof Exception) {
2727: if ($del instanceof Exception) {
2728: warn("cannot remove file `$file' - " . $del->getMessage());
2729: }
2730: continue;
2731:
2732: }
2733: }
2734:
2735: if ($src_stat['link']) {
2736: // rename won't rename a symbolic link; delete and recreate the link
2737: $this->delete(array($this->unmake_path($link)), false);
2738: $this->symlink($src_stat['referent'], $parent);
2739: if ($src_stat['uid'] >= User_Module::MIN_UID || $src_stat['uid'] === APACHE_UID) {
2740: // don't worry about changing
2741: // symlink if owner is root or apache
2742: $nchanged = $lchanged & (int)$this->chown_symlink($parent, $src_stat['owner']);
2743: }
2744:
2745: continue;
2746: }
2747:
2748: // CP runs Dav, Dav uses UPLOAD_UID
2749: if ($src_stat['uid'] == self::UPLOAD_UID) {
2750: chown($src_path, $this->user_id);
2751: chgrp($src_path, $this->group_id);
2752: }
2753: $rename_dest = rtrim($rename_dest, DIRECTORY_SEPARATOR);
2754: $nchanged = rename($src_path, $rename_dest) & $lchanged;
2755: }
2756:
2757: return $nchanged > 0;
2758: }
2759:
2760: /**
2761: * Create a symbolic link
2762: *
2763: * @param string $mSrc source file
2764: * @param string $mDest destination link
2765: * @return bool
2766: */
2767: public function symlink(string $mSrc, string $mDest): bool
2768: {
2769: if (!IS_CLI) {
2770: return $this->query('file_symlink', $mSrc, $mDest) && $this->_purgeCache($mDest);
2771: }
2772:
2773:
2774: $target = '';
2775: if (str_starts_with($mSrc, '..')) {
2776: $mSrc = dirname($mDest) . '/' . $mSrc;
2777: }
2778: if ($mDest[-1] === '/') {
2779: $mDest .= basename($mSrc);
2780: }
2781:
2782: $src_path = $this->make_path($mSrc, $haslink);
2783: if ($haslink) {
2784: $src_path = $haslink;
2785: }
2786: $link = $this->make_path($mDest, $target);
2787: clearstatcache(true, $link);
2788: clearstatcache(true, $src_path);
2789: if (file_exists($link)) {
2790: return error('destination `' . $mDest . "' exists");
2791: }
2792: if (!file_exists($src_path)) {
2793: return error('source `' . $this->unmake_path($src_path) . "' does not exist");
2794: }
2795: if (!is_dir(\dirname($link))) {
2796: return error('Parent directory %s does not exist, cannot create symlink', \dirname($mDest));
2797: }
2798:
2799: // properly calculate relative traversals
2800: $target = Opcenter\Filesystem::abs2rel(realpath(dirname($link)) . '/' . basename($link), $src_path);
2801:
2802: //debug(self::convert_absolute_relative($dest_path, $src_path)." -> ".$dest_path);
2803: return symlink($target, $link) && $this->_purgeCache($mDest) && Util_PHP::lchown($link, $this->user_id)
2804: && Util_PHP::lchgrp($link, $this->group_id);
2805: }
2806:
2807: /**
2808: * Transform absolute path into relative path
2809: *
2810: * @param string $cwd current working directory
2811: * @param string $path target symlink
2812: * @return string
2813: */
2814: public function convert_absolute_relative(string $cwd, string $path): string
2815: {
2816: return \Opcenter\Filesystem::abs2rel($cwd, $path);
2817: }
2818:
2819: /**
2820: * Change ownership of symbolic link
2821: *
2822: * @param string $mFile symbolic link
2823: * @param string $mUser target username
2824: * @return bool
2825: */
2826: public function chown_symlink($mFile, $mUser)
2827: {
2828: if (!IS_CLI) {
2829: $ret = $this->query('file_chown_symlink', $mFile, $mUser);
2830: $this->_purgeCache($mFile);
2831:
2832: return $ret;
2833: }
2834: $validUsers = array_keys($this->user_get_users());
2835: $validUsers[] = \Web_Module::WEB_USERNAME;
2836: if (!in_array($mUser, $validUsers, true)) {
2837: return error("invalid chown user `%s'", $mUser);
2838: }
2839: if (!is_array($mFile)) {
2840: $mFile = array($mFile);
2841: }
2842:
2843: $errors = array();
2844: $uid_cache = $this->user_get_users();
2845:
2846: // RHEL constant UID
2847: $uid_cache[Web_Module::WEB_USERNAME] = array('uid' => APACHE_UID);
2848:
2849: if (!isset($uid_cache[$mUser]['uid'])) {
2850: return new ArgumentError('Eep, unable to find UID for ' . $mUser);
2851: }
2852:
2853: $uid_cache = $uid_cache[$mUser]['uid'];
2854: foreach ($mFile as $file) {
2855: $link = '';
2856: $path = $this->make_path($file, $link);
2857: $stat = $this->stat_backend($this->unmake_path(dirname($link)));
2858: if ($path instanceof Exception) {
2859: $errors[$file] = $path->getMessage();
2860: } else {
2861: if (!$this->hasAccessRights(dirname($path))) {
2862: $errors[$file] = \ArgumentFormatter::format("%s: permission denied", [$file]);
2863: } else if ($stat['can_chown']) {
2864: if (!\Util_PHP::lchown($link, $uid_cache)) {
2865: $errors[$file] = Error_Reporter::get_last_php_msg();
2866: }
2867:
2868: } else {
2869: $errors[$file] = 'Unable to change user ownership of ' . $file;
2870: }
2871: }
2872: }
2873: $this->_purgeCache($mFile);
2874:
2875: if (count($errors)) {
2876: throw new FileError(implode("\n", $errors));
2877: }
2878:
2879: return true;
2880: }
2881:
2882: public function file_exists($file, array &$missing = null)
2883: {
2884: deprecated_func('use exists');
2885:
2886: return $this->exists($file, $missing);
2887: }
2888:
2889: /**
2890: * Check existence of file
2891: *
2892: * @param string|array $file
2893: * @param array $missing files not found
2894: * @return bool|array
2895: */
2896: public function exists(string|array $file, array &$missing = null)
2897: {
2898: if (!IS_CLI && (is_array($file) || !file_exists($this->make_path($file)))) {
2899: return $this->query('file_exists', $file);
2900: }
2901:
2902: /** CLI */
2903: if (!is_array($file)) {
2904: $file = array($file);
2905: }
2906: $exists = true;
2907: $do_missing = is_array($missing);
2908: for ($i = 0, $n = sizeof($file); $i < $n; $i++) {
2909: if (!$exists && $do_missing) {
2910: $missing[] = $file[$i];
2911: }
2912: $path = $this->make_path($file[$i]);
2913: clearstatcache(true, $path);
2914: $exists = file_exists($path);
2915: }
2916:
2917: return $exists;
2918: }
2919:
2920: /**
2921: * Canonicalize jailed filesystem path
2922: *
2923: * @param string $path
2924: * @return string
2925: */
2926: public function canonicalize_site($path)
2927: {
2928: if ($this->permission_level & PRIVILEGE_ADMIN) {
2929: return $path;
2930: }
2931: $prefix = $this->domain_fs_path();
2932: $len = strlen($prefix);
2933: if (0 === strpos($path, $prefix)) {
2934: $path = substr($path, $len);
2935: }
2936:
2937: return !$path ? '/' : $path;
2938: }
2939:
2940: /**
2941: * Canonicalize global path
2942: *
2943: * @param string $path
2944: * @return string
2945: */
2946: public function canonicalize_abs($path)
2947: {
2948:
2949: if ($this->permission_level & PRIVILEGE_ADMIN) {
2950: return $path;
2951: }
2952: $prefix = $this->domain_fs_path();
2953: if (0 !== strpos($path, $prefix)) {
2954: $path = $prefix . $path;
2955: }
2956:
2957: return $path;
2958: }
2959:
2960: /**
2961: * Assume ownership of uploaded files
2962: *
2963: * @param mixed $files
2964: * @return bool
2965: */
2966: public function endow_upload($files)
2967: {
2968: if (!IS_CLI) {
2969: return $this->query('file_endow_upload', $files);
2970: }
2971: if (Error_Reporter::is_error()) {
2972: return error('cannot handle upload in inconsistent state');
2973: }
2974:
2975: if (!is_array($files)) {
2976: $files = array($files);
2977: }
2978:
2979: for ($i = 0, $n = sizeof($files); $i < $n; $i++) {
2980: $file = $files[$i];
2981: if ($file[0] === '.' || $file[0] === '/') {
2982: warn("invalid file to endow upload `%s', skipping (must reside in `%s'", $file, TEMP_DIR);
2983: }
2984: $path = $this->make_path(TEMP_DIR . '/' . $file);
2985: $base = $this->make_path(TEMP_DIR);
2986: if (0 !== strpos($path, $base . '/')) {
2987: error("file `$file' contains invalid characters");
2988: report("Invalid chars? $path $base $file");
2989: continue;
2990: } else {
2991: if (!file_exists($path)) {
2992: error('file `' . TEMP_DIR . "/$file' does not exist");
2993: continue;
2994: } else {
2995: $stat = $this->stat_backend(TEMP_DIR . '/' . $file);
2996: if ($stat['uid'] != self::UPLOAD_UID || $stat['file_type'] != 'file'
2997: || $stat['nlinks'] > 1 || $stat['link'] != 0
2998: ) {
2999: error("file `$file' is not an uploaded file");
3000: continue;
3001: }
3002: }
3003: }
3004: file_exists($path) && chown($path, $this->user_id) && chgrp($path, $this->group_id);
3005: }
3006:
3007: return !Error_Reporter::is_error();
3008: }
3009:
3010: /**
3011: * Create or alter the timestamp of a file
3012: *
3013: * @param string $file filename
3014: * @param int $time optional unix timestamp
3015: * @return bool
3016: */
3017: public function touch(string $file, int $time = null): bool
3018: {
3019: if (!IS_CLI) {
3020: return $this->query('file_touch', $file, $time);
3021: }
3022: if (!$file) {
3023: return error('no filename specified');
3024: }
3025: if (is_null($time)) {
3026: $time = time();
3027: } else if ($time < 0) {
3028: return error("invalid time spec `%d'", $time);
3029: }
3030:
3031: $path = $this->make_path($file);
3032: if (!$path) {
3033: return error('invalid file path `%s', $file);
3034: }
3035: $exists = file_exists($path);
3036: if ($exists) {
3037: $stat = $this->stat_backend($file);
3038: if (!$stat) {
3039: return error("stat failed on `%s'", $file);
3040: } else if (!$stat['can_write']) {
3041: return error("cannot modify file `%s'", $file);
3042: }
3043: } else {
3044: $stat = $this->stat_backend(\dirname($file));
3045: if (!$stat['can_write']) {
3046: return error("Cannot write to file `%s': permission denied", $file);
3047: }
3048: }
3049: $ret = touch($path, $time);
3050: if (!$exists) {
3051: chown($path, (int)$this->user_id);
3052: chgrp($path, (int)$this->group_id);
3053: }
3054:
3055: return $ret;
3056: }
3057:
3058: /**
3059: * Create a pipe destination to compress
3060: *
3061: * @param array $files
3062: * @return string $path
3063: */
3064: public function initialize_download(array $files)
3065: {
3066: if (!IS_CLI) {
3067: return $this->query('file_initialize_download', $files);
3068: }
3069:
3070: // @XXX potential race condition
3071: $fifo = tempnam('/tmp', 'id-' . $this->site);
3072: unlink($fifo);
3073: if (!posix_mkfifo($fifo, 0600)) {
3074: return error('failed to ready pipe for archive');
3075: }
3076:
3077: $newfiles = array();
3078: // do a separate path for unprivileged users
3079: $isUser = $this->permission_level & PRIVILEGE_USER;
3080: foreach ($files as $f) {
3081: if (false !== strpos($f, '..') || $f[0] !== '/') {
3082: // naughty naughty!
3083: continue;
3084: } else if (!isset($f[1])) {
3085: $f = '/.';
3086: }
3087: if ($isUser) {
3088: $stat = $this->stat_backend($f);
3089: if ($stat['uid'] != $this->user_id) {
3090: warn("file `%s' not owned by %s, skipping", $f, $this->username);
3091: continue;
3092: }
3093: }
3094: $newfiles[] = substr($f, 1);
3095: }
3096: if (!$newfiles) {
3097: return error('nothing to download!');
3098: }
3099: $filelist = tempnam('/tmp', 'fl');
3100: chmod($filelist, 0600);
3101: chown($fifo, self::UPLOAD_UID);
3102: file_put_contents($filelist, join("\n", $newfiles));
3103:
3104: $proc = new Util_Process_Fork();
3105:
3106: // lowest priority
3107: $proc->setPriority(19);
3108: // need absolute path to pcntl_exec
3109: $xtrainclude = null;
3110: $ret = $proc->run('/bin/tar --directory %(shadow)s -cf %(fifo)s %(xtrainclude)s --exclude-from=%(skipfile)s ' .
3111: '--one-file-system --files-from=%(list)s ',
3112: array(
3113: 'xtrainclude' => $xtrainclude,
3114: 'shadow' => $this->domain_shadow_path(),
3115: 'fifo' => $fifo,
3116: 'list' => $filelist,
3117: 'skipfile' => INCLUDE_PATH . self::DOWNLOAD_SKIP_LIST
3118: )
3119: );
3120:
3121: return $ret['success'] ? $fifo : false;
3122: }
3123:
3124: /**
3125: * Set file access control lists
3126: *
3127: * - user if omitted removes all ACL entries
3128: * - permissions may be of the form:
3129: * octal (0, 1, 2, 4 and any combo thereof)
3130: * or
3131: * drwx, d sets default
3132: * setting permission null will remove all permissions
3133: * - "0" permission will disallow all access for named user
3134: *
3135: * @param string|array $file
3136: * @param string|null|array $user
3137: * @param int|string|null $permission
3138: * @param array $xtra map of default: bool, recursive: bool (not manageable by subuser)
3139: * @return bool
3140: */
3141: public function set_acls($file, $user = null, $permission = null, array $xtra = array())
3142: {
3143: if (!IS_CLI) {
3144: return $this->query('file_set_acls', $file, $user, $permission, $xtra);
3145: }
3146: if (is_string($permission) && ctype_digit($permission)) {
3147: $permission = (int)$permission;
3148: }
3149: // @todo bring API up to consistent definition
3150: if (!empty($xtra['recursive'])) {
3151: $xtra[self::ACL_MODE_RECURSIVE] = 1;
3152: }
3153: if (!empty($xtra['default'])) {
3154: $xtra[self::ACL_MODE_DEFAULT] = 1;
3155: }
3156:
3157: $uuidmap = $this->permittedUsers();
3158: $file = (array)$file;
3159: $sfiles = array();
3160: $prefix = $this->make_shadow_path('');
3161: $prefixlen = strlen($prefix);
3162: foreach ($file as $tmp) {
3163: $shadow = $this->make_shadow_path($tmp);
3164: $glob = glob($shadow, GLOB_NOSORT);
3165: foreach ($glob as $shadow) {
3166: if (0 !== strpos($shadow, $prefix)) {
3167: // malicious bastard
3168: continue;
3169: }
3170: if (!$shadow) {
3171: error("skipping invalid path `%s'", $tmp);
3172: continue;
3173: }
3174: if (!file_exists($shadow)) {
3175: error("skipping missing path `%s'", $tmp);
3176: continue;
3177: }
3178:
3179: $f = substr($shadow, $prefixlen);
3180: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_ADMIN)) {
3181: // do an extra expensive stat check to ensure ownership
3182: // prevent symlink attacks on /etc/passwd
3183: $stat = $this->stat_backend($f);
3184: if (!$stat['can_chown']) {
3185: error('%s: cannot change ownership attributes', $f);
3186: continue;
3187: }
3188:
3189: }
3190: $sfiles[] = $shadow;
3191: }
3192: }
3193:
3194: if (!$sfiles) {
3195: return error('no files to adjust!');
3196: }
3197:
3198: // never follow symlinks
3199: $flags = '-P';
3200: if (!$user) {
3201: $flags .= 'b';
3202: if (is_array($permission)) {
3203: $xtra = $permission;
3204: $permission = array();
3205: }
3206:
3207: } else if (!is_array($user)) {
3208: $user = array($user => $permission);
3209: } else if (is_array($user) && is_array($permission)) {
3210: // arguments passed in map format
3211: $xtra = $permission;
3212: // arguments passed with implicit true assumption
3213: $permission = null;
3214: } else if (is_array($user)) {
3215: // todo
3216: }
3217: if (array_key_exists(0, $xtra)) {
3218: $xtra = array_fill_keys($xtra, true);
3219: }
3220: $xtra = array_merge(
3221: array(
3222: self::ACL_MODE_DEFAULT => false,
3223: self::ACL_MODE_RECURSIVE => false,
3224: self::ACL_NO_RECALC_MASK => false,
3225: ), $xtra
3226: );
3227: if ($xtra[self::ACL_MODE_DEFAULT]) {
3228: $flags .= 'd';
3229: }
3230: if ($xtra[self::ACL_NO_RECALC_MASK]) {
3231: $flags .= 'n';
3232: }
3233: if (!($this->permission_level & PRIVILEGE_USER) && $xtra[self::ACL_MODE_RECURSIVE]) {
3234: $flags .= 'R';
3235: }
3236: if (0 < ($pos = strspn($flags, self::ACL_FLAGS)) && isset($flags[$pos])) {
3237: return error('unrecognized acl flag: %s', $flags[$pos]);
3238: }
3239: $map = array();
3240:
3241: // just removing acls from all files?
3242: if (!$user) {
3243: return $this->_acl_driver($sfiles, $flags);
3244: }
3245:
3246: // build a permission map
3247: foreach ($user as $u => $perms) {
3248: // passed as [[apache: 7], [foo: 7], [apache: drwx]] ...
3249: if (is_array($perms)) {
3250: $u = key($perms);
3251: $perms = current($perms);
3252: }
3253:
3254: if (!is_int($u) && !isset($uuidmap[$u])) {
3255: return error("invalid user `%s',", $u);
3256: }
3257:
3258: $default = false;
3259: $flag = 'm';
3260: if (is_null($perms)) {
3261: $flag = 'x';
3262: } else if (!ctype_digit((string)$perms)) {
3263: if (0 < ($pos = strspn($perms, 'drwx')) && isset($perms[$pos])) {
3264: // permissions provided as chars, verify it's sensible
3265: return error("unknown permission mode `%s' setting for user `%s'",
3266: $perms[$pos], $u
3267: );
3268: }
3269: $tmp = 0;
3270: for ($i = 0, $n = strlen($perms); $i < $n; $i++) {
3271: if ($perms[$i] === 'r') {
3272: $tmp |= 4;
3273: } else if ($perms[$i] === 'w') {
3274: $tmp |= 2;
3275: } else if ($perms[$i] === 'x') {
3276: $tmp |= 1;
3277: } else if ($perms[$i] === 'd' && !$xtra[self::ACL_MODE_DEFAULT]) {
3278: $default = true;
3279: }
3280: }
3281: $perms = $tmp;
3282: }
3283:
3284: $uid = is_int($u) ? $u : $uuidmap[$u];
3285: $map[] = sprintf('-%s %su:%u%s',
3286: $flag,
3287: ($default ? 'd:' : ''),
3288: $uid,
3289: (is_null($perms) ? null : ':' . $perms)
3290: );
3291: }
3292:
3293: if (!$this->_acl_driver($sfiles, $flags, $map)) {
3294: return false;
3295: }
3296: /**
3297: * Flush cached ACL entries
3298: *
3299: * @todo unify acl cache management
3300: */
3301: $cache = \Cache_Account::spawn($this->getAuthContext());
3302: foreach (array_unique(array_map('\dirname', $file)) as $dir) {
3303: $key = $this->site_id . '|' . $dir;
3304: unset($this->acl_cache[$key]);
3305: $cache->hDel('acl', $dir);
3306: }
3307:
3308: return true;
3309: }
3310:
3311: private function _acl_driver(array $files, $flags, array $rights = array())
3312: {
3313: $shadow = $this->domain_shadow_path();
3314: if ($flags[0] !== '-') {
3315: return error('acl flags garbled');
3316: }
3317: if (0 !== strpos($files[0], $shadow)) {
3318: return error('crit: acl path error?!!');
3319: }
3320:
3321: $cmd = 'setfacl ' . $flags . ' ' . join(' ', $rights);
3322: $cmd .= str_repeat(' %s', count($files));
3323: $proc = Util_Process_Safe::exec($cmd, $files);
3324:
3325: if (!$proc['success']) {
3326: return error("setting ACLs failed: `%s'", coalesce($proc['stderr'], $proc['stdout']));
3327: }
3328:
3329: return true;
3330: }
3331:
3332: /**
3333: * Build up shadow layer component wrapper
3334: * {@see shadow_buildup_backend}
3335: *
3336: * @param string $path file or directory to verify
3337: * @return bool
3338: */
3339: public function shadow_buildup($path)
3340: {
3341: // validate permissions permit
3342: $parent = dirname($path);
3343: $tok = strtok($parent, '/');
3344: $chkpath = '';
3345: do {
3346: $chkpath .= '/' . $tok;
3347: if (!$this->exists($chkpath)) {
3348: break;
3349: }
3350: } while (false !== ($tok = strtok('/')));
3351: $chkpath = \dirname($chkpath);
3352: $stat = $this->file_stat($chkpath);
3353: if (!$stat['can_write'] || !$stat['can_descend']) {
3354: return error('Cannot build up path %s: permission denied by %s', $path, $chkpath);
3355: }
3356:
3357: return $this->query('file_shadow_buildup_backend', $path, $this->user_id);
3358:
3359: }
3360:
3361: /**
3362: * Build up shadow layer components up to the final piece
3363: *
3364: * @param string $path file or directory to verify
3365: * @param string|int $user user to set on buildup (default: current user on non CLI)
3366: * @param int $perm permission in octal form
3367: * @return bool
3368: */
3369: public function shadow_buildup_backend($path, $user = 'root', $perm = 0755)
3370: {
3371: if (version_compare(platform_version(), '6', '<')) {
3372: // bypass on Helios (aufs)
3373: return true;
3374: }
3375: $shadowprefix = $this->domain_shadow_path();
3376: $prefix = $this->domain_fs_path();
3377: /**
3378: * Flexible parsing on path input
3379: */
3380: if (0 === strpos($path, $prefix)) {
3381: $path = substr($path, strlen($prefix));
3382: }
3383: if (0 !== strpos($path, $shadowprefix)) {
3384: $path = $this->make_shadow_path($path);
3385: }
3386: $parent = dirname($path);
3387: $tok = strtok($parent, '/');
3388: $chkpath = '';
3389: do {
3390: $chkpath .= '/' . $tok;
3391: if (!file_exists($chkpath)) {
3392: break;
3393: }
3394:
3395: } while (false !== ($tok = strtok('/')));
3396:
3397: if (false === $tok) {
3398: return true;
3399: }
3400:
3401: if (0 === strpos($chkpath, $shadowprefix)) {
3402: $chkpath = $this->domain_shadow_path() .
3403: substr($chkpath, strlen($shadowprefix));
3404: }
3405: do {
3406: \Opcenter\Filesystem::mkdir($chkpath, $user, $this->group_id, $perm);
3407: $remaining = strtok('/');
3408: $chkpath .= '/' . $remaining;
3409: } while (false !== $remaining);
3410:
3411: // and drop OverlayFS cache
3412: return $this->purge();
3413: }
3414:
3415: /**
3416: * Reset ownership of files in a path
3417: *
3418: * @see takeover_user() to change ownership without altering permissions
3419: *
3420: * @param string $path
3421: * @param null|string $user set to null to bypass user reset, blank string current user
3422: * @param string|int $fileperm file permission to reset
3423: * @param int $dirperm directory permission to reset
3424: * @return bool
3425: */
3426: public function reset_path(string $path, ?string $user = '', $fileperm = 644, $dirperm = 755): bool
3427: {
3428: if (!IS_CLI) {
3429: return $this->query('file_reset_path', $path, $user, $fileperm, $dirperm);
3430: }
3431:
3432: $usercmd = null;
3433: $acceptableUids = [
3434: $this->user_get_uid_from_username(\Web_Module::WEB_USERNAME),
3435: ];
3436: if ($user === '') {
3437: $user = $this->username;
3438: }
3439: if ($user) {
3440: $uid = (int)$user;
3441: if ($uid !== $user) {
3442: $uid = $this->user_get_uid_from_username($user);
3443: }
3444: if ($this->tomcat_permitted()) {
3445: $acceptableUids[] = $this->user_get_uid_from_username($this->tomcat_system_user());
3446: }
3447:
3448: if ($uid < \User_Module::MIN_UID && !in_array($uid, $acceptableUids, true)) {
3449: return error("user `%s' is unknown or a system user", $user);
3450: }
3451: $usercmd = '-exec chown -h ' . (int)$uid . ' "{}" \+';
3452: }
3453:
3454: $shadowpath = $this->make_shadow_path($path);
3455: if (!file_exists($shadowpath)) {
3456: return error("path `%s' does not exist", $path);
3457: }
3458:
3459: if (is_int($fileperm)) {
3460: $fileperm = (string)$fileperm;
3461: }
3462:
3463: if (is_int($dirperm)) {
3464: $dirperm = (string)$dirperm;
3465: }
3466:
3467: $stat = $this->stat_backend($path, false);
3468: if (!$stat['can_write']) {
3469: return error("cannot reset path `%s' without write permissions", $path);
3470: } else if ($stat['uid'] < \User_Module::MIN_UID && !in_array($stat['uid'], $acceptableUids)) {
3471: return error("unable to takeover, base path `%s' must be within acceptable UID range", $path);
3472: } else if ($fileperm[0] !== '0' && strlen((string)$fileperm) > 3) {
3473: return error('special perms may not be set for files');
3474: } else if ($dirperm[0] !== '0' && strlen((string)$dirperm) > 3) {
3475: return error('special perms may not be set for directories');
3476: } else if (strlen((string)$fileperm) !== strspn((string)$fileperm, '01234567')) {
3477: return error('file permission must be octal');
3478: } else if (strlen((string)$dirperm) !== strspn((string)$dirperm, '01234567')) {
3479: return error('directory permission must be octal');
3480: }
3481:
3482: $args = [
3483: 'path' => $shadowpath,
3484: 'gid' => $this->group_id,
3485: 'fperm' => $fileperm,
3486: 'dperm' => $dirperm,
3487: ];
3488: $ret = \Util_Process_Safe::exec(
3489: 'find -P %(path)s -xdev -gid %(gid)d ' . $usercmd . ' \( -type f -exec chmod %(fperm)s "{}" \+ \) ' .
3490: '-o \( -type d -exec chmod %(dperm)s "{}" \+ \) -printf "%%P\n"',
3491: $args
3492: );
3493: if (!$ret['success']) {
3494: return error('failed to reset path, err: %s', $ret['stderr']);
3495: }
3496: $files = explode("\n", rtrim($ret['stdout']));
3497: if (!$files) {
3498: warn('no files changed');
3499: }
3500: $this->purge();
3501:
3502: return $ret['success'];
3503: }
3504:
3505: /**
3506: * Recursively convert ownership of files from one user to another
3507: *
3508: * @param string|int $olduser owner to convert
3509: * @param string|int $newuser new owner
3510: * @param string $path base path
3511: * @return bool|array
3512: */
3513: public function takeover_user($olduser, $newuser, string $path = '/')
3514: {
3515: if (!IS_CLI) {
3516: return $this->query('file_takeover_user', $olduser, $newuser, $path);
3517: }
3518: $newuid = (int)$newuser;
3519: $olduid = (int)$olduser;
3520: if ($olduid !== $olduser) {
3521: $olduid = $this->user_get_uid_from_username($olduser);
3522: }
3523: if ($newuid !== $newuser) {
3524: $newuid = $this->user_get_uid_from_username($newuser);
3525: }
3526: $acceptableUids = $this->permittedUsers();
3527:
3528: if ($olduid < \User_Module::MIN_UID && !in_array($olduid, $acceptableUids)) {
3529: return error("user `%s' is unknown or a system user", $olduser);
3530: }
3531:
3532: if ($newuid < \User_Module::MIN_UID && !in_array($newuid, $acceptableUids)) {
3533: return error("user `%s' is unknown or a system user", $newuser);
3534: }
3535: $shadowpath = $this->make_shadow_path($path);
3536: $stat = $this->stat_backend($path, false);
3537: if (!file_exists($shadowpath)) {
3538: return error("path `%s' does not exist", $path);
3539: } else if ($path !== '/' && $stat['uid'] < \User_Module::MIN_UID && !in_array($stat['uid'], $acceptableUids)) {
3540: return error("unable to takeover, base path `%s' must be within acceptable UID range", $path);
3541: }
3542: $args = [
3543: 'path' => $shadowpath,
3544: 'gid' => $this->group_id,
3545: 'olduid' => $olduid,
3546: 'newuid' => $newuid
3547: ];
3548: $ret = \Util_Process_Safe::exec(
3549: 'find -P %(path)s -xdev -gid %(gid)d -uid %(olduid)d -exec chown -h %(newuid)d "{}" \; -printf "%%P\n"',
3550: $args
3551: );
3552: if (!$ret['success']) {
3553: return error('failed to convert ownership, err: %s', $ret['stderr']);
3554: }
3555: $files = explode("\n", rtrim($ret['stdout']));
3556: if (!$files) {
3557: warn('no files changed');
3558: }
3559:
3560: $this->purge(true);
3561:
3562: return $files;
3563: }
3564:
3565: /**
3566: * Scan a given path for malicious files
3567: *
3568: * @param string $path
3569: * @return string|null scan results or null on error
3570: */
3571: public function scan(string $path): ?string
3572: {
3573: if (!ANTIVIRUS_INSTALLED) {
3574: error('No AV installed');
3575: return null;
3576: }
3577: $fstpath = $this->make_shadow_path($path);
3578: $prefix = $this->domain_shadow_path();
3579: if (!\count(glob($fstpath . '/*'))) {
3580: return null;
3581: }
3582: $ret = \Util_Process_Safe::exec(
3583: 'clamdscan -mi %(path)s/*',
3584: ['path' => $fstpath],
3585: [0],
3586: ['reporterror' => false]
3587: );
3588: if (!$ret['success']) {
3589: // filter out needless warnings
3590: $ret['stderr'] = preg_replace('/^WARNING: .*$[\r\n]?/m', '', $ret['stderr']);
3591: if ($ret['stderr']) {
3592: error('Failed to scan %s: %s',
3593: $path,
3594: $ret['stderr']
3595: );
3596: return null;
3597: }
3598: warn('Potential malware discovered');
3599: }
3600: $output = [];
3601: $tok = strtok($ret['output'], "\n");
3602: $prefixlen = strlen($prefix);
3603: while (false !== $tok) {
3604: $output[] = 0 === strpos($tok, $prefix) ? substr($tok, $prefixlen) : $tok;
3605: $tok = strtok("\n");
3606: }
3607: return implode("\n", $output);
3608: }
3609:
3610: public function _delete()
3611: {
3612: // make sure we dump overlayfs' page cache
3613: if (version_compare(platform_version(), '6.5', '>=')) {
3614: $this->purge();
3615: }
3616: }
3617: }