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