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