1:    2:    3:    4:    5:    6:    7:    8:    9:   10:   11:   12:   13:   14:   15:   16:   17:   18:   19:   20:   21:   22:   23:   24:   25:   26:   27:   28:   29:   30:   31:   32:   33:   34:   35:   36:   37:   38:   39:   40:   41:   42:   43:   44:   45:   46:   47:   48:   49:   50:   51:   52:   53:   54:   55:   56:   57:   58:   59:   60:   61:   62:   63:   64:   65:   66:   67:   68:   69:   70:   71:   72:   73:   74:   75:   76:   77:   78:   79:   80:   81:   82:   83:   84:   85:   86:   87:   88:   89:   90:   91:   92:   93:   94:   95:   96:   97:   98:   99:  100:  101:  102:  103:  104:  105:  106:  107:  108:  109:  110:  111:  112:  113:  114:  115:  116:  117:  118:  119:  120:  121:  122:  123:  124:  125:  126:  127:  128:  129:  130:  131:  132:  133:  134:  135:  136:  137:  138:  139:  140:  141:  142:  143:  144:  145:  146:  147:  148:  149:  150:  151:  152:  153:  154:  155:  156:  157:  158:  159:  160:  161:  162:  163:  164:  165:  166:  167:  168:  169:  170:  171:  172:  173:  174:  175:  176:  177:  178:  179:  180:  181:  182:  183:  184:  185:  186:  187:  188:  189:  190:  191:  192:  193:  194:  195:  196:  197:  198:  199:  200:  201:  202:  203:  204:  205:  206:  207:  208:  209:  210:  211:  212:  213:  214:  215:  216:  217:  218:  219:  220:  221:  222:  223:  224:  225:  226:  227:  228:  229:  230:  231:  232:  233:  234:  235:  236:  237:  238:  239:  240:  241:  242:  243:  244:  245:  246:  247:  248:  249:  250:  251:  252:  253:  254:  255:  256:  257:  258:  259:  260:  261:  262:  263:  264:  265:  266:  267:  268:  269:  270:  271:  272:  273:  274:  275:  276:  277:  278:  279:  280:  281:  282:  283:  284:  285:  286:  287:  288:  289:  290:  291:  292:  293:  294:  295:  296:  297:  298:  299:  300:  301:  302:  303:  304:  305:  306:  307:  308:  309:  310:  311:  312:  313:  314:  315:  316:  317:  318:  319:  320:  321:  322:  323:  324:  325:  326:  327:  328:  329:  330:  331:  332:  333:  334:  335:  336:  337:  338:  339:  340:  341:  342:  343:  344:  345:  346:  347:  348:  349:  350:  351:  352:  353:  354:  355:  356:  357:  358:  359:  360:  361:  362:  363:  364:  365:  366:  367:  368:  369:  370:  371:  372:  373:  374:  375:  376:  377:  378:  379:  380:  381:  382:  383:  384:  385:  386:  387:  388:  389:  390:  391:  392:  393:  394:  395:  396:  397:  398:  399:  400:  401:  402:  403:  404:  405:  406:  407:  408:  409:  410:  411:  412:  413:  414:  415:  416:  417:  418:  419:  420:  421:  422:  423:  424:  425:  426:  427:  428:  429:  430:  431:  432:  433:  434:  435:  436:  437:  438:  439:  440:  441:  442:  443:  444:  445:  446:  447:  448:  449:  450:  451:  452:  453:  454:  455:  456:  457:  458:  459:  460:  461:  462:  463:  464:  465:  466:  467:  468:  469:  470:  471:  472:  473:  474:  475:  476:  477:  478:  479:  480:  481:  482:  483:  484:  485:  486:  487:  488:  489:  490:  491:  492:  493:  494:  495:  496:  497:  498:  499:  500:  501:  502:  503:  504:  505:  506:  507:  508:  509:  510:  511:  512:  513:  514:  515:  516:  517:  518:  519:  520:  521:  522:  523:  524:  525:  526:  527:  528:  529:  530:  531:  532:  533:  534:  535:  536:  537:  538:  539:  540:  541:  542:  543:  544:  545:  546:  547:  548:  549:  550:  551:  552:  553:  554:  555:  556:  557:  558:  559:  560:  561:  562:  563:  564:  565:  566:  567:  568:  569:  570:  571:  572:  573:  574:  575:  576:  577:  578:  579:  580:  581:  582:  583:  584:  585:  586:  587:  588:  589:  590:  591:  592:  593:  594:  595:  596:  597:  598:  599:  600:  601:  602:  603:  604:  605:  606:  607:  608:  609:  610:  611:  612:  613:  614:  615:  616:  617:  618:  619:  620:  621:  622:  623:  624:  625:  626:  627:  628:  629:  630:  631:  632:  633:  634:  635:  636:  637:  638:  639:  640:  641:  642:  643:  644:  645:  646:  647:  648:  649:  650:  651:  652:  653:  654:  655:  656:  657:  658:  659:  660:  661:  662:  663:  664:  665:  666:  667:  668:  669:  670:  671:  672:  673:  674:  675:  676:  677:  678:  679:  680:  681:  682:  683:  684:  685:  686:  687:  688:  689:  690:  691:  692:  693:  694:  695:  696:  697:  698:  699:  700:  701:  702:  703:  704:  705:  706:  707:  708:  709:  710:  711:  712:  713:  714:  715:  716:  717:  718:  719:  720:  721:  722:  723:  724:  725:  726:  727:  728:  729:  730:  731:  732:  733:  734:  735:  736:  737:  738:  739:  740:  741:  742:  743:  744:  745:  746:  747:  748:  749:  750:  751:  752:  753:  754:  755:  756:  757:  758:  759:  760:  761:  762:  763:  764:  765:  766:  767:  768:  769:  770:  771:  772:  773:  774:  775:  776:  777:  778:  779:  780:  781:  782:  783:  784:  785:  786:  787:  788:  789:  790:  791:  792:  793:  794:  795:  796:  797:  798:  799:  800:  801:  802:  803:  804:  805:  806:  807:  808:  809:  810:  811:  812:  813:  814:  815:  816:  817:  818:  819:  820:  821:  822:  823:  824:  825:  826:  827:  828:  829:  830:  831:  832:  833:  834:  835:  836:  837:  838:  839:  840:  841:  842:  843:  844:  845:  846:  847:  848:  849:  850:  851:  852:  853:  854:  855:  856:  857:  858:  859:  860:  861:  862:  863:  864:  865:  866:  867:  868:  869:  870:  871:  872:  873:  874:  875:  876:  877:  878:  879:  880:  881:  882:  883:  884:  885:  886:  887:  888:  889:  890:  891:  892:  893:  894:  895:  896:  897:  898:  899:  900:  901:  902:  903:  904:  905:  906:  907:  908:  909:  910:  911:  912:  913:  914:  915:  916:  917:  918:  919:  920:  921:  922:  923:  924:  925:  926:  927:  928:  929:  930:  931:  932:  933:  934:  935:  936:  937:  938:  939:  940:  941:  942:  943:  944:  945:  946:  947:  948:  949:  950:  951:  952:  953:  954:  955:  956:  957:  958:  959:  960:  961:  962:  963:  964:  965:  966:  967:  968:  969:  970:  971:  972:  973:  974:  975:  976:  977:  978:  979:  980:  981:  982:  983:  984:  985:  986:  987:  988:  989:  990:  991:  992:  993:  994:  995:  996:  997:  998:  999: 1000: 1001: 1002: 1003: 1004: 1005: 1006: 1007: 1008: 1009: 1010: 1011: 1012: 1013: 1014: 1015: 1016: 1017: 1018: 1019: 1020: 1021: 1022: 1023: 1024: 1025: 1026: 1027: 1028: 1029: 1030: 1031: 1032: 1033: 1034: 1035: 1036: 1037: 1038: 1039: 1040: 1041: 1042: 1043: 1044: 1045: 1046: 1047: 1048: 1049: 1050: 1051: 1052: 1053: 1054: 1055: 1056: 1057: 1058: 1059: 1060: 1061: 1062: 1063: 1064: 1065: 1066: 1067: 1068: 1069: 1070: 1071: 1072: 1073: 1074: 1075: 1076: 1077: 1078: 1079: 1080: 1081: 1082: 1083: 1084: 1085: 1086: 1087: 1088: 1089: 1090: 1091: 1092: 1093: 1094: 1095: 1096: 1097: 1098: 1099: 1100: 1101: 1102: 1103: 1104: 1105: 1106: 1107: 1108: 1109: 1110: 1111: 1112: 1113: 1114: 1115: 1116: 1117: 1118: 1119: 1120: 1121: 1122: 1123: 1124: 1125: 1126: 1127: 1128: 1129: 1130: 1131: 1132: 1133: 1134: 1135: 1136: 1137: 1138: 1139: 1140: 1141: 1142: 1143: 1144: 1145: 1146: 1147: 1148: 1149: 1150: 1151: 1152: 1153: 1154: 1155: 1156: 1157: 1158: 1159: 1160: 1161: 1162: 1163: 1164: 1165: 1166: 1167: 1168: 1169: 1170: 1171: 1172: 1173: 1174: 1175: 1176: 1177: 1178: 1179: 1180: 1181: 1182: 1183: 1184: 1185: 1186: 1187: 1188: 1189: 1190: 1191: 1192: 1193: 1194: 1195: 1196: 1197: 1198: 1199: 1200: 1201: 1202: 1203: 1204: 1205: 1206: 1207: 1208: 1209: 1210: 1211: 1212: 1213: 1214: 1215: 1216: 1217: 1218: 1219: 1220: 1221: 1222: 1223: 1224: 1225: 1226: 1227: 1228: 1229: 1230: 1231: 1232: 1233: 1234: 1235: 1236: 1237: 1238: 1239: 1240: 1241: 1242: 1243: 1244: 1245: 1246: 1247: 1248: 1249: 1250: 1251: 1252: 1253: 1254: 1255: 1256: 1257: 1258: 1259: 1260: 1261: 1262: 1263: 1264: 1265: 1266: 1267: 1268: 1269: 1270: 1271: 1272: 1273: 1274: 1275: 1276: 1277: 1278: 1279: 1280: 1281: 1282: 1283: 1284: 1285: 1286: 1287: 1288: 1289: 1290: 1291: 1292: 1293: 1294: 1295: 1296: 1297: 1298: 1299: 1300: 1301: 1302: 1303: 1304: 1305: 1306: 1307: 1308: 1309: 1310: 1311: 1312: 1313: 1314: 1315: 1316: 1317: 1318: 1319: 1320: 1321: 1322: 1323: 1324: 1325: 1326: 1327: 1328: 1329: 1330: 1331: 1332: 1333: 1334: 1335: 1336: 1337: 1338: 1339: 1340: 1341: 1342: 1343: 1344: 1345: 1346: 1347: 1348: 1349: 1350: 1351: 1352: 1353: 1354: 1355: 1356: 1357: 1358: 1359: 1360: 1361: 1362: 1363: 1364: 1365: 1366: 1367: 1368: 1369: 1370: 1371: 1372: 1373: 1374: 1375: 1376: 1377: 1378: 1379: 1380: 1381: 1382: 1383: 1384: 1385: 1386: 1387: 1388: 1389: 1390: 1391: 1392: 1393: 1394: 1395: 1396: 1397: 1398: 1399: 1400: 1401: 1402: 1403: 1404: 1405: 1406: 1407: 1408: 1409: 1410: 1411: 1412: 1413: 1414: 1415: 1416: 1417: 1418: 1419: 1420: 1421: 1422: 1423: 1424: 1425: 1426: 1427: 1428: 1429: 1430: 1431: 1432: 1433: 1434: 1435: 1436: 1437: 1438: 1439: 1440: 1441: 1442: 1443: 1444: 1445: 1446: 1447: 1448: 1449: 1450: 1451: 1452: 1453: 1454: 1455: 1456: 1457: 1458: 1459: 1460: 1461: 1462: 1463: 1464: 1465: 1466: 1467: 1468: 1469: 1470: 1471: 1472: 1473: 1474: 1475: 1476: 1477: 1478: 1479: 1480: 1481: 1482: 1483: 1484: 1485: 1486: 1487: 1488: 1489: 1490: 1491: 1492: 1493: 1494: 1495: 1496: 1497: 1498: 1499: 1500: 1501: 1502: 1503: 1504: 1505: 1506: 1507: 1508: 1509: 1510: 1511: 1512: 1513: 1514: 1515: 1516: 1517: 1518: 1519: 1520: 1521: 1522: 1523: 1524: 1525: 1526: 1527: 1528: 1529: 1530: 1531: 1532: 1533: 1534: 1535: 1536: 1537: 1538: 1539: 1540: 1541: 1542: 1543: 1544: 1545: 1546: 1547: 1548: 1549: 1550: 1551: 1552: 1553: 1554: 1555: 1556: 1557: 1558: 1559: 1560: 1561: 1562: 1563: 1564: 1565: 1566: 1567: 1568: 1569: 1570: 1571: 1572: 1573: 1574: 1575: 1576: 1577: 1578: 1579: 1580: 1581: 1582: 1583: 1584: 1585: 1586: 1587: 1588: 1589: 1590: 1591: 1592: 1593: 1594: 1595: 1596: 1597: 1598: 1599: 1600: 1601: 1602: 1603: 1604: 1605: 1606: 1607: 1608: 1609: 1610: 1611: 1612: 1613: 1614: 1615: 1616: 1617: 1618: 1619: 1620: 1621: 1622: 1623: 1624: 1625: 1626: 1627: 1628: 1629: 1630: 1631: 1632: 1633: 1634: 1635: 1636: 1637: 1638: 1639: 1640: 1641: 1642: 1643: 1644: 1645: 1646: 1647: 1648: 1649: 1650: 1651: 1652: 1653: 1654: 1655: 1656: 1657: 1658: 1659: 1660: 1661: 1662: 1663: 1664: 1665: 1666: 1667: 1668: 1669: 1670: 1671: 1672: 1673: 1674: 1675: 1676: 1677: 1678: 1679: 1680: 1681: 1682: 1683: 1684: 1685: 1686: 1687: 1688: 1689: 1690: 1691: 1692: 1693: 1694: 1695: 1696: 1697: 1698: 1699: 1700: 1701: 1702: 1703: 1704: 1705: 1706: 1707: 1708: 1709: 1710: 1711: 1712: 1713: 1714: 1715: 1716: 1717: 1718: 1719: 1720: 1721: 1722: 1723: 1724: 1725: 1726: 1727: 1728: 1729: 1730: 1731: 1732: 1733: 1734: 1735: 1736: 1737: 1738: 1739: 1740: 1741: 1742: 1743: 1744: 1745: 1746: 1747: 1748: 1749: 1750: 1751: 1752: 1753: 1754: 1755: 1756: 1757: 1758: 1759: 1760: 1761: 1762: 1763: 1764: 1765: 1766: 1767: 1768: 1769: 1770: 1771: 1772: 1773: 1774: 1775: 1776: 1777: 1778: 1779: 1780: 1781: 1782: 1783: 1784: 1785: 1786: 1787: 1788: 1789: 1790: 1791: 1792: 1793: 1794: 1795: 1796: 1797: 1798: 1799: 1800: 1801: 1802: 1803: 1804: 1805: 1806: 1807: 1808: 1809: 1810: 1811: 1812: 1813: 1814: 1815: 1816: 1817: 1818: 1819: 1820: 1821: 1822: 1823: 1824: 1825: 1826: 1827: 1828: 1829: 1830: 1831: 1832: 1833: 1834: 1835: 1836: 1837: 1838: 1839: 1840: 1841: 1842: 1843: 1844: 1845: 1846: 1847: 1848: 1849: 1850: 1851: 1852: 1853: 1854: 1855: 1856: 1857: 1858: 1859: 1860: 1861: 1862: 1863: 1864: 1865: 1866: 1867: 1868: 1869: 1870: 1871: 1872: 1873: 1874: 1875: 1876: 1877: 1878: 1879: 1880: 1881: 1882: 1883: 1884: 1885: 1886: 1887: 1888: 1889: 1890: 1891: 1892: 1893: 1894: 1895: 1896: 1897: 1898: 1899: 1900: 1901: 1902: 1903: 1904: 1905: 1906: 1907: 1908: 1909: 1910: 1911: 1912: 1913: 1914: 1915: 1916: 1917: 1918: 1919: 1920: 1921: 1922: 1923: 1924: 1925: 1926: 1927: 1928: 1929: 1930: 1931: 1932: 1933: 1934: 1935: 1936: 1937: 1938: 1939: 1940: 1941: 1942: 1943: 1944: 1945: 1946: 1947: 1948: 1949: 1950: 1951: 1952: 1953: 1954: 1955: 1956: 1957: 1958: 1959: 1960: 1961: 1962: 1963: 1964: 1965: 1966: 1967: 1968: 1969: 1970: 1971: 1972: 1973: 1974: 1975: 1976: 1977: 1978: 1979: 1980: 1981: 1982: 1983: 1984: 1985: 1986: 1987: 1988: 1989: 1990: 1991: 1992: 1993: 1994: 1995: 1996: 1997: 1998: 1999: 2000: 2001: 2002: 2003: 2004: 2005: 2006: 2007: 2008: 2009: 2010: 2011: 2012: 2013: 2014: 2015: 2016: 2017: 2018: 2019: 2020: 2021: 2022: 2023: 2024: 2025: 2026: 2027: 2028: 2029: 2030: 2031: 2032: 2033: 2034: 2035: 2036: 2037: 2038: 2039: 2040: 2041: 2042: 2043: 2044: 2045: 2046: 2047: 2048: 2049: 2050: 2051: 2052: 2053: 2054: 2055: 2056: 2057: 2058: 2059: 2060: 2061: 2062: 2063: 2064: 2065: 2066: 2067: 2068: 2069: 2070: 2071: 2072: 2073: 2074: 2075: 2076: 2077: 2078: 2079: 2080: 2081: 2082: 2083: 2084: 2085: 2086: 2087: 2088: 2089: 2090: 2091: 2092: 2093: 2094: 2095: 2096: 2097: 2098: 2099: 2100: 2101: 2102: 2103: 2104: 2105: 2106: 2107: 2108: 2109: 2110: 2111: 2112: 2113: 2114: 2115: 2116: 2117: 2118: 2119: 2120: 2121: 2122: 2123: 2124: 2125: 2126: 2127: 2128: 2129: 2130: 2131: 2132: 2133: 2134: 2135: 2136: 2137: 2138: 2139: 2140: 2141: 2142: 2143: 2144: 2145: 2146: 2147: 2148: 2149: 2150: 2151: 2152: 2153: 2154: 2155: 2156: 2157: 2158: 2159: 2160: 2161: 2162: 2163: 2164: 2165: 2166: 2167: 2168: 2169: 2170: 2171: 2172: 2173: 2174: 2175: 2176: 2177: 2178: 2179: 2180: 2181: 2182: 2183: 2184: 2185: 2186: 2187: 2188: 2189: 2190: 2191: 2192: 2193: 2194: 2195: 2196: 2197: 2198: 2199: 2200: 2201: 2202: 2203: 2204: 2205: 2206: 2207: 2208: 2209: 2210: 2211: 2212: 2213: 2214: 2215: 2216: 2217: 2218: 2219: 2220: 2221: 2222: 2223: 2224: 2225: 2226: 2227: 2228: 2229: 2230: 2231: 2232: 2233: 2234: 2235: 2236: 2237: 2238: 2239: 2240: 2241: 2242: 2243: 2244: 2245: 2246: 2247: 2248: 2249: 2250: 2251: 2252: 2253: 2254: 2255: 2256: 2257: 2258: 2259: 2260: 2261: 2262: 2263: 2264: 2265: 2266: 2267: 2268: 2269: 2270: 2271: 2272: 2273: 2274: 2275: 2276: 2277: 2278: 2279: 2280: 2281: 2282: 2283: 2284: 2285: 2286: 2287: 2288: 2289: 2290: 2291: 2292: 2293: 2294: 2295: 2296: 2297: 2298: 2299: 2300: 2301: 2302: 2303: 2304: 2305: 2306: 2307: 2308: 2309: 2310: 2311: 2312: 2313: 2314: 2315: 2316: 2317: 2318: 2319: 2320: 2321: 2322: 2323: 2324: 2325: 2326: 2327: 2328: 2329: 2330: 2331: 2332: 2333: 2334: 2335: 2336: 2337: 2338: 2339: 2340: 2341: 2342: 2343: 2344: 2345: 2346: 2347: 2348: 2349: 2350: 2351: 2352: 2353: 2354: 2355: 2356: 2357: 2358: 2359: 2360: 2361: 2362: 2363: 2364: 2365: 2366: 2367: 2368: 2369: 2370: 2371: 2372: 2373: 2374: 2375: 2376: 2377: 2378: 2379: 2380: 2381: 2382: 2383: 2384: 2385: 2386: 2387: 2388: 2389: 2390: 2391: 2392: 2393: 2394: 2395: 2396: 2397: 2398: 2399: 2400: 2401: 2402: 2403: 2404: 2405: 2406: 2407: 2408: 2409: 2410: 2411: 2412: 2413: 2414: 2415: 2416: 2417: 2418: 2419: 2420: 2421: 2422: 2423: 2424: 2425: 2426: 2427: 2428: 2429: 2430: 2431: 2432: 2433: 2434: 2435: 2436: 2437: 2438: 2439: 2440: 2441: 2442: 2443: 2444: 2445: 2446: 2447: 2448: 2449: 2450: 2451: 2452: 2453: 2454: 2455: 2456: 2457: 2458: 2459: 2460: 2461: 2462: 2463: 2464: 2465: 2466: 2467: 2468: 2469: 2470: 2471: 2472: 2473: 2474: 2475: 2476: 2477: 2478: 2479: 2480: 2481: 2482: 2483: 2484: 2485: 2486: 2487: 2488: 2489: 2490: 2491: 2492: 2493: 2494: 2495: 2496: 2497: 2498: 2499: 2500: 2501: 2502: 2503: 2504: 2505: 2506: 2507: 2508: 2509: 2510: 2511: 2512: 2513: 2514: 2515: 2516: 2517: 2518: 2519: 2520: 2521: 2522: 2523: 2524: 2525: 2526: 2527: 2528: 2529: 2530: 2531: 2532: 2533: 2534: 2535: 2536: 2537: 2538: 2539: 2540: 2541: 2542: 2543: 2544: 2545: 2546: 2547: 2548: 2549: 2550: 2551: 2552: 2553: 2554: 2555: 2556: 2557: 2558: 2559: 2560: 2561: 2562: 2563: 2564: 2565: 2566: 2567: 2568: 2569: 2570: 2571: 2572: 2573: 2574: 2575: 2576: 2577: 2578: 2579: 2580: 2581: 2582: 2583: 2584: 2585: 2586: 2587: 2588: 2589: 2590: 2591: 2592: 2593: 2594: 2595: 2596: 2597: 2598: 2599: 2600: 2601: 2602: 2603: 2604: 2605: 2606: 2607: 2608: 2609: 2610: 2611: 2612: 2613: 2614: 2615: 2616: 2617: 2618: 2619: 2620: 2621: 2622: 2623: 2624: 2625: 2626: 2627: 2628: 2629: 2630: 2631: 2632: 2633: 2634: 2635: 2636: 2637: 2638: 2639: 2640: 2641: 2642: 2643: 2644: 2645: 2646: 2647: 2648: 2649: 2650: 2651: 2652: 2653: 2654: 2655: 2656: 2657: 2658: 2659: 2660: 2661: 2662: 2663: 2664: 2665: 2666: 2667: 2668: 2669: 2670: 2671: 2672: 2673: 2674: 2675: 2676: 2677: 2678: 2679: 2680: 2681: 2682: 2683: 2684: 2685: 2686: 2687: 2688: 2689: 2690: 2691: 2692: 2693: 2694: 2695: 2696: 2697: 2698: 2699: 2700: 2701: 2702: 2703: 2704: 2705: 2706: 2707: 2708: 2709: 2710: 2711: 2712: 2713: 2714: 2715: 2716: 2717: 2718: 2719: 2720: 2721: 2722: 2723: 2724: 2725: 2726: 2727: 2728: 2729: 2730: 2731: 2732: 2733: 2734: 2735: 2736: 2737: 2738: 2739: 2740: 2741: 2742: 2743: 2744: 2745: 2746: 2747: 2748: 2749: 2750: 2751: 2752: 2753: 2754: 2755: 2756: 2757: 2758: 2759: 2760: 2761: 2762: 2763: 2764: 2765: 2766: 2767: 2768: 2769: 2770: 2771: 2772: 2773: 2774: 2775: 2776: 2777: 2778: 2779: 2780: 2781: 2782: 2783: 2784: 2785: 2786: 2787: 2788: 2789: 2790: 2791: 2792: 2793: 2794: 2795: 2796: 2797: 2798: 2799: 2800: 2801: 2802: 2803: 2804: 2805: 2806: 2807: 2808: 2809: 2810: 2811: 2812: 2813: 2814: 2815: 2816: 2817: 2818: 2819: 2820: 2821: 2822: 2823: 2824: 2825: 2826: 2827: 2828: 2829: 2830: 2831: 2832: 2833: 2834: 2835: 2836: 2837: 2838: 2839: 2840: 2841: 2842: 2843: 2844: 2845: 2846: 2847: 2848: 2849: 2850: 2851: 2852: 2853: 2854: 2855: 2856: 2857: 2858: 2859: 2860: 2861: 2862: 2863: 2864: 2865: 2866: 2867: 2868: 2869: 2870: 2871: 2872: 2873: 2874: 2875: 2876: 2877: 2878: 2879: 2880: 2881: 2882: 2883: 2884: 2885: 2886: 2887: 2888: 2889: 2890: 2891: 2892: 2893: 2894: 2895: 2896: 2897: 2898: 2899: 2900: 2901: 2902: 2903: 2904: 2905: 2906: 2907: 2908: 2909: 2910: 2911: 2912: 2913: 2914: 2915: 2916: 2917: 2918: 2919: 2920: 2921: 2922: 2923: 2924: 2925: 2926: 2927: 2928: 2929: 2930: 2931: 2932: 2933: 2934: 2935: 2936: 2937: 2938: 2939: 2940: 2941: 2942: 2943: 2944: 2945: 2946: 2947: 2948: 2949: 2950: 2951: 2952: 2953: 2954: 2955: 2956: 2957: 2958: 2959: 2960: 2961: 2962: 2963: 2964: 2965: 2966: 2967: 2968: 2969: 2970: 2971: 2972: 2973: 2974: 2975: 2976: 2977: 2978: 2979: 2980: 2981: 2982: 2983: 2984: 2985: 2986: 2987: 2988: 2989: 2990: 2991: 2992: 2993: 2994: 2995: 2996: 2997: 2998: 2999: 3000: 3001: 3002: 3003: 3004: 3005: 3006: 3007: 3008: 3009: 3010: 3011: 3012: 3013: 3014: 3015: 3016: 3017: 3018: 3019: 3020: 3021: 3022: 3023: 3024: 3025: 3026: 3027: 3028: 3029: 3030: 3031: 3032: 3033: 3034: 3035: 3036: 3037: 3038: 3039: 3040: 3041: 3042: 3043: 3044: 3045: 3046: 3047: 3048: 3049: 3050: 3051: 3052: 3053: 3054: 3055: 3056: 3057: 3058: 3059: 3060: 3061: 3062: 3063: 3064: 3065: 3066: 3067: 3068: 3069: 3070: 3071: 3072: 3073: 3074: 3075: 3076: 3077: 3078: 3079: 3080: 3081: 3082: 3083: 3084: 3085: 3086: 3087: 3088: 3089: 3090: 3091: 3092: 3093: 3094: 3095: 3096: 3097: 3098: 3099: 3100: 3101: 3102: 3103: 3104: 3105: 3106: 3107: 3108: 3109: 3110: 3111: 3112: 3113: 3114: 3115: 3116: 3117: 3118: 3119: 3120: 3121: 3122: 3123: 3124: 3125: 3126: 3127: 3128: 3129: 3130: 3131: 3132: 3133: 3134: 3135: 3136: 3137: 3138: 3139: 3140: 3141: 3142: 3143: 3144: 3145: 3146: 3147: 3148: 3149: 3150: 3151: 3152: 3153: 3154: 3155: 3156: 3157: 3158: 3159: 3160: 3161: 3162: 3163: 3164: 3165: 3166: 3167: 3168: 3169: 3170: 3171: 3172: 3173: 3174: 3175: 3176: 3177: 3178: 3179: 3180: 3181: 3182: 3183: 3184: 3185: 3186: 3187: 3188: 3189: 3190: 3191: 3192: 3193: 3194: 3195: 3196: 3197: 3198: 3199: 3200: 3201: 3202: 3203: 3204: 3205: 3206: 3207: 3208: 3209: 3210: 3211: 3212: 3213: 3214: 3215: 3216: 3217: 3218: 3219: 3220: 3221: 3222: 3223: 3224: 3225: 3226: 3227: 3228: 3229: 3230: 3231: 3232: 3233: 3234: 3235: 3236: 3237: 3238: 3239: 3240: 3241: 3242: 3243: 3244: 3245: 3246: 3247: 3248: 3249: 3250: 3251: 3252: 3253: 3254: 3255: 3256: 3257: 3258: 3259: 3260: 3261: 3262: 3263: 3264: 3265: 3266: 3267: 3268: 3269: 3270: 3271: 3272: 3273: 3274: 3275: 3276: 3277: 3278: 3279: 3280: 3281: 3282: 3283: 3284: 3285: 3286: 3287: 3288: 3289: 3290: 3291: 3292: 3293: 3294: 3295: 3296: 3297: 3298: 3299: 3300: 3301: 3302: 3303: 3304: 3305: 3306: 3307: 3308: 3309: 3310: 3311: 3312: 3313: 3314: 3315: 3316: 3317: 3318: 3319: 3320: 3321: 3322: 3323: 3324: 3325: 3326: 3327: 3328: 3329: 3330: 3331: 3332: 3333: 3334: 3335: 3336: 3337: 3338: 3339: 3340: 3341: 3342: 3343: 3344: 3345: 3346: 3347: 3348: 3349: 3350: 3351: 3352: 3353: 3354: 3355: 3356: 3357: 3358: 3359: 3360: 3361: 3362: 3363: 3364: 3365: 3366: 3367: 3368: 3369: 3370: 3371: 3372: 3373: 3374: 3375: 3376: 3377: 3378: 3379: 3380: 3381: 3382: 3383: 3384: 3385: 3386: 3387: 3388: 3389: 3390: 3391: 3392: 3393: 3394: 3395: 3396: 3397: 3398: 3399: 3400: 3401: 3402: 3403: 3404: 3405: 3406: 3407: 3408: 3409: 3410: 3411: 3412: 3413: 3414: 3415: 3416: 3417: 3418: 3419: 3420: 3421: 3422: 3423: 3424: 3425: 3426: 3427: 3428: 3429: 3430: 3431: 3432: 3433: 3434: 3435: 3436: 3437: 3438: 3439: 3440: 3441: 3442: 3443: 3444: 3445: 3446: 3447: 3448: 3449: 3450: 3451: 3452: 3453: 3454: 3455: 3456: 3457: 3458: 3459: 3460: 3461: 3462: 3463: 3464: 3465: 3466: 3467: 3468: 3469: 3470: 3471: 3472: 3473: 3474: 3475: 3476: 3477: 3478: 3479: 3480: 3481: 3482: 3483: 3484: 3485: 3486: 3487: 3488: 3489: 3490: 3491: 3492: 3493: 3494: 3495: 3496: 3497: 3498: 3499: 3500: 3501: 3502: 3503: 3504: 3505: 3506: 3507: 3508: 3509: 3510: 3511: 3512: 3513: 3514: 3515: 3516: 3517: 3518: 3519: 3520: 3521: 3522: 3523: 3524: 3525: 3526: 3527: 3528: 3529: 3530: 3531: 3532: 3533: 3534: 3535: 3536: 3537: 3538: 3539: 3540: 3541: 3542: 3543: 3544: 3545: 3546: 3547: 3548: 3549: 3550: 3551: 3552: 3553: 3554: 3555: 3556: 3557: 3558: 3559: 3560: 3561: 3562: 3563: 3564: 3565: 3566: 3567: 3568: 3569: 3570: 3571: 3572: 3573: 3574: 3575: 3576: 3577: 3578: 3579: 3580: 3581: 3582: 3583: 3584: 3585: 3586: 3587: 3588: 3589: 3590: 3591: 3592: 3593: 3594: 3595: 3596: 3597: 3598: 3599: 3600: 3601: 3602: 3603: 3604: 3605: 3606: 3607: 3608: 3609: 3610: 3611: 3612: 3613: 3614: 3615: 3616: 3617: 3618: 3619: 3620: 3621: 3622: 3623: 3624: 3625: 3626: 3627: 3628: 3629: 3630: 3631: 
<?php declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    /**
     * Provides file interaction and ACL support
     *
     * @package core
     *
     * @todo    add xattr support
     */
    class File_Module extends Module_Skeleton
    {
        const DEPENDENCY_MAP = [
            'siteinfo',
            'diskquota'
        ];
        const UPLOAD_UID = WS_UID;
        const STCACHE_ROOT = '6666cd76f96956469e7be39d750cc7d9';  // md5("/")
        const ACL_MODE_RECURSIVE = 'R';
        const ACL_MODE_DEFAULT = 'd';
        const ACL_NO_RECALC_MASK = 'n';
        const ACL_FLAGS = '-PRbdkxn';
        // under apnscp root
        const DOWNLOAD_SKIP_LIST = '/config/file_download_skiplist.txt';
        // values used in computing referent permissions
        const LINK_KEYMAP = [
            'gid'  => 1,
            'uid'  => 1,
            'mode' => 1,
            'ino'  => 1
        ];
        private static $registered_extensions = array(
            'zip'     => 'zip',
            'tgz'     => 'gzip',
            'tar'     => 'tar',
            'tar.gz'  => 'gzip',
            'gz'      => 'gzip',
            'bz'      => 'bzip',
            'bz2'     => 'bzip',
            'tar.bz'  => 'bzip',
            'tar.bz2' => 'bzip',
            'tbz'     => 'bzip',
            'tbz2'    => 'bzip'
        );
        private $stat_cache = [];
        // assume all operations exist on shadow/
        // if user is admin, bypass costly stat checks
        private $acl_cache = [];
        private $uid_translation = [];
        private $compression_instances;

        // apply settings recursively
        private $trans_paths = array();
        // apply settings as default
        private $cached;
        // don't recalculate effective rights mask
        private $clearstat = false;
        // all valid ACL flags
        private $_optimizedShadowAssertion = 1;

        /**
         * {{{ void __construct(void)
         *
         * @ignore
         */
        public function __construct()
        {
            parent::__construct();

            foreach (array_unique(array_values(self::$registered_extensions)) as $iface) {
                $this->compression_instances[$iface] = null;
            }
            if ($this->_optimizedShadowAssertion && version_compare(platform_version(), '6', '>=')) {
                $this->_optimizedShadowAssertion = 2;
            }

            $this->exportedFunctions = array(
                '*'                               => PRIVILEGE_ALL,
                'canonicalize_site'               => PRIVILEGE_SITE | PRIVILEGE_USER,
                'change_file_permissions_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'chmod_backend'                   => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'delete_backend'                  => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'fix_apache_perms_backend'        => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
                'get_directory_contents_backend'  => PRIVILEGE_SERVER_EXEC | PRIVILEGE_ALL,
                'get_file_contents_backend'       => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'lookup_chroot_pwnam'             => PRIVILEGE_SERVER_EXEC,
                'move_backend'                    => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'put_file_contents_backend'       => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'report_quota'                    => PRIVILEGE_SITE,
                'report_quota_backend'            => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
                'shadow_buildup_backend'          => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'stat_backend'                    => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC,
                'takeover_user'                   => PRIVILEGE_SITE,
                'scan'                            => ANTIVIRUS_INSTALLED ? PRIVILEGE_SITE : PRIVILEGE_NONE
            );

            $this->__wakeup();
        }

        public function __wakeup()
        {
            $this->cached = Cache_User::spawn($this->getAuthContext());
        }

        /**
         * Transform relative path into an absolute path
         *
         * @param string $file     absolute location of a path
         * @param string $referent relative path
         * @return string
         */
        public static function convert_relative_absolute($file, $referent)
        {
            if (false !== ($file_rel = strstr($file, '..'))) {
                $file = self::convert_relative_absolute(substr($file, 0, strpos($file, '..')), $file_rel);
            }

            // fetch the rest of the string
            $file_com = explode('/', dirname($file));
            $token = strtok($referent, '/');

            if ($token != '..') {
                return "${file}/${referent}";
            }
            array_pop($file_com);
            while (false !== ($token = strtok('/'))) {
                if ($token == '..') {
                    array_pop($file_com);
                } else if ($token) {
                    $file_com[] = $token;
                    break;
                } else {
                    continue;
                }
            }

            $path = join('/', $file_com);

            while (false !== ($token = strtok('/'))) {
                if (!$token)               // path: /
                {
                    $path .= '/';
                } else if ($token == '..') { // path: ..
                    return self::convert_relative_absolute($path, strtok(''));
                } else {
                    $path .= '/' . $token;
                }
            }

            return $path . strtok('');
        }

        /**
         * array get_registered_extensions ()
         * Keys from the return value correspond to the extension type,
         * values are the classes all implementing the common interface
         * Compression_Interface
         *
         * @return array list of known compression extensions and their corresponding
         * interfaces.
         */
        public function get_registered_extensions()
        {
            return self::$registered_extensions;
        }

        /**
         * Extract files from archive
         *
         *
         * @param  string $archive   archive file
         * @param  string $dest      destination directory
         * @param  bool   $overwrite overwrite destination files if source exists
         * @return bool
         */
        public function extract($archive, $dest, $overwrite = true)
        {
            if (!IS_CLI) {
                $ret = $this->query('file_extract', $archive, $dest);

                return $ret;
            }

            $class = $this->initialize_interface($archive);
            $archive_path = $this->make_path($archive);
            $destination_path = $this->make_path($dest);
            $tmp_path = $this->_mktmpdir(storage_path('tmp'), 'ee');
            if ($archive_path instanceof Exception) {
                return $archive_path;
            } else {
                if ($destination_path instanceof Exception) {
                    return $destination_path;
                }
            }

            $archive_stat = $this->stat_backend($archive);
            $destination_stat = $this->stat_backend($dest);
            if (!file_exists($destination_path) && !$this->create_directory($dest, 0755, true)) {
                return false;
            } else if (!$this->can_descend($destination_path, true)) {
                return error($dest . ': unable to write to directory');
            }
            if ($archive_stat instanceof Exception) {
                return $archive_stat;
            } else {
                if ($destination_stat instanceof Exception && !$destination_stat instanceof FileError) {
                    return $destination_stat;
                }
            }

            mkdir($tmp_path);
            chmod($tmp_path, 0700);
            $ret = $class->extract_files($archive_path, $tmp_path);
            if ($ret instanceof Exception) {
                return $ret;
            }

            /** now that we have the files extracted, chown */
            if (!strpos($tmp_path, '/', 1)) {
                return error('path creation failure');
            }
            $ret = 0;
            $flags = '-aHWxq';
            if (!$overwrite) {
                $flags .= ' --ignore-existing';
            }
            $proc = Util_Process_Safe::exec(
                '/bin/chown -h -R %s:%s %s && rsync ' . $flags . ' %s/ %s/ && rm -rf %s/',
                $this->user_id, $this->group_id, $tmp_path, $tmp_path, $destination_path, $tmp_path
            );

            chmod($destination_path, 0755);

            return $proc['success'];
        }

        /**
         * Initialize compression driver for file
         *
         * @private
         * @param  string $file
         * @return object class instance
         */
        private function initialize_interface($file)
        {
            if (!$this->is_compressed($file)) {
                return error($file . ': not a recognized compressed file');
            }
            $ext = substr($this->compression_extension($file), 1);
            if (!$ext) {
                return error($file . ': internal error determining archive extension');
            }
            if (isset($this->compression_instances[$ext])) {
                return $this->compression_instances[$ext];
            }

            $base_dir = INCLUDE_PATH . '/lib/modules/compression/';
            $module = self::$registered_extensions[$ext];
            if (!file_exists($base_dir . '/' . $module . '.php')) {
                return error($module . ': compression filter not found');
            }

            if (!class_exists(ucwords($module) . '_Filter', false)) {
                if (!interface_exists('IArchive', false)) {
                    include($base_dir . '/iarchive.php');
                }
                if (!class_exists('Archive_Base', false)) {
                    include($base_dir . '/base.php');
                }
                include($base_dir . '/' . $module . '.php');
            }

            $c = ucwords($module) . '_Filter';
            $class = new $c($this);

            $class->init($this);
            $this->compression_instances[$ext] = $class;

            return $this->compression_instances[$ext];
        }

        /**
         * bool is_compressed (string)
         * Checks to see if a file is a compressed archive through a
         * bit of guestimation
         *
         * @param  string $mFile
         * @return bool
         */
        public function is_compressed($mFile)
        {
            $extTmp = explode('.', $mFile);
            $ext = array_pop($extTmp);
            /** may be .tar.gz for example */
            if (!isset(self::$registered_extensions[$ext])) {
                $ext2 = array_pop($extTmp);
                if (isset(self::$registered_extensions[implode('.', array($ext2, $ext))])) {
                    return true;
                } else {
                    return false;
                }
            } else {
                return true;
            }

        }

        /**
         * Extract compression extension from file
         *
         * @param  $file
         * @return string
         */
        public function compression_extension($file)
        {
            if (!$this->is_compressed($file)) {
                return false;
            }

            $extTmp = explode('.', $file);
            if (sizeof($extTmp) > 2) {
                $ext = join('.', array_slice($extTmp, -2));
            }

            if (sizeof($extTmp) <= 2 || !isset(self::$registered_extensions[$ext])) {
                $ext = join('', array_slice($extTmp, -1));
            }

            $this->compression_ext = $ext;

            return '.' . $ext;
        }

        /**
         * Returns a path outside the chroot'd environment
         *
         * @TODO tokenize
         *
         * @param  string $path
         * @param  string $link translated symbolic link path
         * @return string|false
         */
        public function make_path(string $path, ?string &$link = '')
        {
            if (isset($this->trans_paths[$this->site_id][$path])) {
                $path = $this->trans_paths[$this->site_id][$path];
                $link = $path[1];

                return $path[0];
            }
            // we really don't know how to handle relative files

            if (!isset($path[0])) {
                return $this->domain_fs_path();
            } else if ($path[0] === '~') {
                $path = $this->user_get_home() . substr($path, 1);
            } else if ($path[0] !== '/') {
                // bubble up for brevity
                throw new \Exception($path . ': path must be absolute');
            }
            $root = '';
            $newpath = str_replace('//', '/', $path);
            $link = '';

            if (($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER))) {
                $root = $this->domain_fs_path();
            }

            if (\Util_PHP::is_link($root . $newpath)) {
                $link = $root . $newpath;
                if (file_exists($link) && (string)readlink($link)[0] == '/') {
                    $newpath = realpath($link);
                } else {
                    $tmp = (string)realpath($link);
                    if (0 === strpos($tmp, $root)) {
                        $newpath = substr($tmp, strlen($root));
                    }
                }
            }
            for ($pathCom = explode('/', $newpath), $i = sizeof($pathCom); $i > 0; $i--) {
                $pathTest = $root . implode('/', array_slice($pathCom, 0, $i));
                if (file_exists($pathTest)) {
                    break;
                }
            }
            if (isset($root[1]) &&
                0 !== strpos(realpath($pathTest), $root)
            ) {
                // let's assume they made a symlink to /var/www/html/ instead of ../../var/www/
                //if (!file_exists($this->domain_fs_path().realpath($pathTest)))

                $newpath = $root . $pathTest;
                //return new FileError("Invalid path detected");
            }
            if (!self::sanitized($newpath)) {
                throw new \Exception($newpath . ': Garbage characters in file ');
            }
            $newpath = $root . str_replace('//', '/', $newpath);
            if (!isset($this->trans_paths[$this->site_id])) {
                $this->trans_paths[$this->site_id] = array();
            }
            $this->trans_paths[$this->site_id][$path] = array($newpath, $link);
            $this->trans_paths[$newpath] = $path;

            return $newpath;
        }

        /**
         * Verify file name is legal
         *
         *
         * @param mixed $file file or directory name to check for malicious
         *                    characters
         * @return bool
         */
        public static function sanitized($file)
        {
            return true;
        }

        protected function _mktmpdir($path, $prefix = '')
        {
            $dir = $path . '/' . uniqid($prefix);
            if (file_exists($dir)) {
                return $this->_mktmpdir($path, $prefix);
            }

            return $dir;
        }

        /**
         * Give information about a file
         *
         * @see stat()
         * @param string $file
         * @return array
         */
        public function stat_backend($file, $shadow = false)
        {
            $link = '';
            $link_type = 0;
            $path = $shadow ? $this->make_shadow_path($file, $link) :
                $this->make_path($file, $link);

            if (!$path) {
                return error("failed to translate path `%s'", $path);
            }

            $filemtime = -1;
            if (!$link && !file_exists($path)) {
                return array();
            }
            $prefix = '';
            if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
                $prefix = $shadow ? $this->domain_shadow_path() : $this->domain_fs_path();
            }
            // real path
            if ($link) {
                $pathbase = dirname($link);
            } else {
                $pathbase = dirname($path);
            }
            if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER) && 0 === strpos($prefix, $pathbase)) {
                $pathbase = $prefix;
            }

            // virtual path
            $file = rtrim($file, '/');
            $vpathbase = rtrim(dirname($file), '/');
            $dirhash = md5($vpathbase);
            $filename = basename($file);

            // $file = "/"
            if (!isset($filename[0])) {
                $filename = '.';
            } else if ($filename[0] === '~') {
                $filename = basename($this->user_get_home());
            }
            $filehash = md5($filename);

            if ($this->clearstat) {
                clearstatcache(false);
                $this->clearstat = false;
            }

            $prefixlen = strlen($prefix);
            $siteid = $this->site_id;
            $dh = opendir($pathbase);
            if (!$dh) {
                return error("cannot open `%s'", dirname($file));
            }
            Error_Reporter::mute_warning(true);
            $stats = array();

            while (false !== ($dirent = readdir($dh))) {
                if ($dirent === '..') {
                    continue;
                }
                $portable_link = true;
                $path = $pathbase . '/' . $dirent;
                /**
                 * PHP 7.x ZTS regression, . will always refer to the current
                 * directory and a link cannot be a directory, thus
                 * \Util_PHP::is_link("/home/symlink/.") should be false as in non-ZTS builds
                 */
                $islink = $dirent !== '.' && \Util_PHP::is_link($prefix . $vpathbase . '/' . $dirent);
                $vfile = $vpathbase . '/' . $dirent;
                $enthash = md5($dirent);
                if ($islink === false) {
                    $stat_details = stat($path);
                } else {
                    $tmp = $vpathbase . '/' . $dirent;
                    $referent = $shadow ? $this->make_shadow_path($tmp) : $this->make_path($tmp);
                    $vreferent = substr($referent, $prefixlen);
                    if (!file_exists($referent)) {
                        $vreferent = null;
                        $portable_link = 0;
                    } else {
                        $link = readlink($prefix . $vpathbase . '/' . $dirent);
                        $portable_link = $link[0] != '/';
                    }
                    $link_type = $referent && is_dir($referent) ? 2 : 1;
                    $refstat = [];
                    if (file_exists($path)) {
                        $refstat = stat($path);
                    }
                    $stat_details = (array)array_intersect_key($refstat, self::LINK_KEYMAP) + lstat($path);
                }

                if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
                    // fetch uid/gid from chroot'd filesystem
                    $owner = $this->lookup_chroot_pwnam($stat_details['uid']);
                    $group = $this->lookup_chroot_pwnam($stat_details['gid']);
                } else {
                    // otherwise we can use normal posix functions to query /etc/passwd
                    $owner = posix_getpwuid($stat_details['uid']);
                    $owner = $owner['name'];
                    $group = posix_getgrgid($stat_details['gid']);
                    $group = $group['name'];
                }

                $acl = 0;
                // next gather the ACLs on the file
                // if file is a symbolic link, then skip it
                if (!$islink && $vpathbase !== '.') {
                    $acls = $this->get_acls($vfile);
                    if ($acls) {
                        $pwusr = $this->lookup_chroot_pwnam($this->user_id);
                        $pwgrp = $this->lookup_chroot_pwnam($this->group_id);
                        foreach ($acls as $item) {
                            if (isset($item['user']) && $item['user'] == $pwusr) {
                                $acl = $item['permissions'];
                                break;
                            } else if (isset($item['group']) && $item['group'] == $pwgrp) {
                                $acl = $item['permissions'];
                            }
                        }
                    }
                }
                $vstat = array(
                    'filename'    => $dirent,
                    'owner'       => $owner ?: $stat_details['uid'],
                    'group'       => $group ?: $stat_details['gid'],
                    'uid'         => $stat_details['uid'],
                    'gid'         => $stat_details['gid'],
                    'size'        => $stat_details['size'],
                    'file_type'   => $islink ? 'link' : filetype($path),
                    'referent'    => $islink ? $vreferent : null,
                    'portable'    => $portable_link,
                    'link'        => !$islink ? 0 : $link_type,
                    'nlinks'      => $stat_details['nlink'],
                    'permissions' => $islink ? 41471 : $stat_details['mode'],
                    'site_quota'  => $stat_details['gid'] == $this->group_id,
                    'user_quota'  => $stat_details['uid'] == $this->user_id,
                    'ctime'       => $stat_details['ctime'],
                    'mtime'       => $stat_details['mtime'],
                    'atime'       => $stat_details['atime'],
                    'inode'       => $stat_details['ino'],
                    'sid'         => $this->site_id,
                    'can_write'   => $acl & 2 ||
                        ($this->permission_level & PRIVILEGE_SITE) &&
                        ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID /* apache */) ||
                        $stat_details['uid'] == $this->user_id && $stat_details['mode'] & 0x0080 ||
                        ($stat_details['gid'] == $this->group_id && $stat_details['mode'] & 0x0010) &&
                        !($stat_details['mode'] & 0x0200) ||
                        $stat_details['gid'] != $this->group_id && $stat_details['mode'] & 0x0002,

                    'can_read' => $acl & 4 ||
                        ($this->permission_level & PRIVILEGE_SITE) &&
                        ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID) ||
                        $stat_details['uid'] == $this->user_id && $stat_details['mode'] & 0x0100 ||
                        $stat_details['gid'] == $this->group_id && $stat_details['mode'] & 0x0020 ||
                        $stat_details['gid'] != $this->group_id && $stat_details['mode'] & 0x0004,

                    'can_execute' => $acl & 1 ||
                        ($this->permission_level & PRIVILEGE_SITE) &&
                        ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID) ||
                        $stat_details['uid'] == $this->user_id && $stat_details['mode'] & 0x0040 ||
                        $stat_details['gid'] == $this->group_id && $stat_details['mode'] & 0x0008 ||
                        $stat_details['gid'] != $this->group_id && $stat_details['mode'] & 0x0001,

                    'can_chown' => ($this->permission_level & PRIVILEGE_SITE) && /** super user */
                        ($stat_details['gid'] == $this->group_id || $stat_details['uid'] == APACHE_UID ||
                            ($stat_details['gid'] == APACHE_UID)) ||
                        $this->permission_level & PRIVILEGE_USER && $stat_details['uid'] == $this->user_id,

                    'can_chgrp' => (bool)($this->permission_level & PRIVILEGE_ADMIN)
                );
                $stats[$enthash] = $vstat;
            }
            closedir($dh);
            Error_Reporter::unmute_warning();
                        // special case root fs for accounts

            $cachekey = $this->_getCacheKey($file);
            if (!$this->cached->set($cachekey, $stats, 120)) {
                Error_Reporter::report("FAIL ADD: $file ($cachekey) - msg " . $this->cached->getResultMessage());
            }
            //$this->cached->delete($cachekey);

            if (!isset($stats[$filehash])) {
                if (!$shadow) {
                    return $this->stat_backend($file, true);
                }
                if (is_debug()) {
                    $newpath = str_replace('/fst', '/shadow', $pathbase);
                    var_dump(`ls -la $pathbase ; ls -la $newpath`);
                    var_dump('EMER: Missed hash!?!!!@', $vpathbase, $filename, $dirhash, $filehash,
                        $stats[$siteid][$dirhash]);
                    die();
                }
                $data = "ASKED: $filehash ($filename)" . "\r\n\r\n" . var_export($stats, true);
                report('MISSED HASH: ' . $data);
            }

            return $stats[$filehash];
        }

        /**
         * Resolve path as shadow
         *
         * @param        $path
         * @param string $link
         * @return string
         */
        public function make_shadow_path($path, &$link = '')
        {
            $path = $this->make_path($path, $link);
            $prefix = $this->domain_fs_path();

            return $this->domain_shadow_path() . substr($path, strlen($prefix));
        }

        /**
         * mixed lookup_chroot_pwnam (integer)
         *
         * @param integer $uid user id to lookup
         * @return string transformed name of the uid.
         *                     Transforms the uid the username within a chroot'd environment
         */
        private function lookup_chroot_pwnam($uid)
        {
            if (!$uid) {
                return 'root';
            }
            if (!isset($this->uid_translation[$uid])) {
                $this->uid_translation[$uid] = $this->user_get_username_from_uid($uid);
            }

            return $this->uid_translation[$uid];
        }

        /**
         * Perform ACL lookup on files
         *
         * @param  string $file filename
         * @return array
         */
        public function get_acls($file)
        {
            if (0 === strncmp($file, "/proc", 5)) {
                return array();
            } else if (!IS_CLI) {
                $ret = $this->query('file_get_acls', $file);

                return $ret;
            }

            $optimized = false;
            if ($this->permission_level & PRIVILEGE_SITE) {
                $optimized = $this->_optimizedShadowAssertion;
            }

            if ($optimized) {
                // only copy off shadow, if in the future ro -> rw branch
                // propagation can setgid copy-ups, then maybe switch back to make_path()
                // do not use shadow path as dest if luna+ (OverlayFS)
                $path = $this->make_shadow_path($file);
                $prefixlen = strlen($this->domain_shadow_path());
            } else {
                $path = $this->make_path($file);
                $prefixlen = strlen($this->domain_fs_path());
            }

            if (!$path) {
                return $path;
            }

            $cache_key = $this->site_id . '|' . dirname($file);
            $apcu_key = 'acl:' . $cache_key;
            $acl_dir = $path;

            if (!isset($this->acl_cache[$cache_key])) {
                $acl_dir = dirname($path);
                $cache = \Cache_Account::spawn($this->getAuthContext());
                $entry = $cache->get($apcu_key);

                if (false !== $entry) {
                    $this->acl_cache = array_merge_recursive($this->acl_cache,
                        [$cache_key => $entry]);

                    return $entry[basename($path)] ?? [];
                }
            }

            // acl updates only happen through one command
            if (isset($this->acl_cache[$cache_key])) {
                return $this->acl_cache[$cache_key][basename($file)]['aclinfo'] ?? [];
            }

            if (!is_readable($path)) {
                return [];
            }

            if (!is_dir($acl_dir)) {
                $acl_dir = dirname($path);
            }

            // conceal any warnings about missing files in the glob pattern
            $path_safe = escapeshellarg($acl_dir);
            $path_safe = str_replace('%', '%%', $path_safe);
            // ignore non-ACL entries
            $cmd = sprintf('getfacl --skip-base --absolute-names --omit-header --numeric --tabular ' .
                '--all-effective %s/ %s/.[!.]* %s/..?* %s/*',
                $path_safe,
                $path_safe,
                $path_safe,
                $path_safe);
            $data = Util_Process::exec($cmd, array(0, 1), array('mute_stderr' => true));
            /**
             * @TODO: Cache expensive ACL lookups
             */

            $isChroot = $this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER);
            $data['output'] = preg_replace_callback('/\\\\(\d{3})/',
                static function ($match) {
                    return chr(octdec($match[1]));
                }, $data['output']);
            //if (!$data['output']) {
            //  warn($cmd.": no response");
            //}

            $acl_cache = array();
            foreach (explode("\n\n", $data['output']) as $entry) {
                if (0 !== strncmp($entry, '# file:', 7)) {
                    continue;
                }
                $acls = array();
                $entpath = (string)substr($entry, 8, strpos($entry, "\n") - 8);
                if (strrchr($entpath, '/') == '/.' || strrchr($entpath, '/') == '/..') {
                    continue;
                }

                /** skip .. and . entries */
                foreach (explode("\n", $entry) as $line) {
                    if (preg_match(Regex::GETFACL_ACL, $line, $aclMatches)) {
                        $perms = 0;
                        if ($aclMatches[1] == 'USER') {
                            $type = 'euser';
                        } else if ($aclMatches[1] == 'GROUP') {
                            $type = 'egroup';
                        } else {
                            $type = $aclMatches[1];
                        }

                        if (strtolower($aclMatches[3][0]) == 'r') {
                            $perms |= 4;
                        }
                        if (strtolower($aclMatches[3][1]) == 'w') {
                            $perms |= 2;
                        }
                        if (strtolower($aclMatches[3][2]) == 'x') {
                            $perms |= 1;
                        }
                        $identifier = $isChroot ? $this->lookup_chroot_pwnam((int)$aclMatches[2]) : $aclMatches[2];
                        if (($type === 'egroup' || $type === 'group') && $aclMatches[2] == $this->group_id) {
                            $identifier = array_get(posix_getgrgid($this->group_id), 'name');
                        }
                        $acls[] = array(
                            $type         => $identifier,
                            'permissions' => $perms
                        );
                    }
                }
                $aclkey = basename($entpath);
                $acl_cache[$cache_key][$aclkey] = array(
                    'mtime'   => filemtime($entpath),
                    'aclinfo' => $acls
                );
            }
            $cache = \Cache_Account::spawn($this->getAuthContext());
            $cache->set($apcu_key, $acl_cache, 60);
            $this->acl_cache = array_merge($this->acl_cache, $acl_cache);

            return $this->acl_cache[$cache_key][basename($file)]['aclinfo'] ?? [];
        }

        private function _getCacheKey($file)
        {
            $cachebase = $this->_getCacheDir($file);

            return 's:' . md5($cachebase);
        }

        private function _getCacheDir($file)
        {
            return dirname($file);
        }

        /**
         * bool create_directory (string[, int[, bool]])
         * Creates a directory within the filesystem and will recursively
         * create parent directories if need be
         *
         * @param string  $dir       directory name to create
         * @param integer $mode      mode to create the file
         * @param bool    $recursive recursively create directory
         * @return bool
         */
        public function create_directory($dir, $mode = 0755, $recursive = false): bool
        {
            if (!is_int($mode)) {
                return error($mode . ': invalid mode');
            }
            if (!IS_CLI) {
                return $this->query('file_create_directory', $dir, $mode, $recursive);
            }

            $path = $this->make_path($dir);
            clearstatcache(true, $path);
            $dir2mk = array();
            if (!$recursive && !file_exists(dirname($path))) {
                return error(dirname($dir) . ': no such file/directory');
            }
            if (file_exists($path)) {
                if (is_dir($path)) {
                    return true;
                } else {
                    return warn('%s: file exists', $dir);
                }
            }
            //

            $dir = $this->unmake_path($path);
            $curpath = '';
            $curdir = strtok($dir, '/');
            $pathpfx = $this->domain_fs_path();

            do {
                $curpath .= '/' . $curdir;
                $fullpath = $pathpfx . $curpath;
                if (!file_exists($fullpath)) {
                    $dir2mk[] = $fullpath;
                }
            } while (false !== ($curdir = (strtok('/'))));
            if (!$dir2mk) {
                // @XXX weird aufs bug, initial stat reports no
                // but incremental dir buildup reports file_exists()
                // possible delay in cache?
                // triggered in litmus basic test no 6,
                // "mkcol_over_plain" -> mkdir over file
                return is_dir($fullpath);
            }
            $parent = dirname($dir2mk[0]);
            $pstat = $this->stat($this->unmake_path($parent));

            if ($pstat instanceof Exception) {
                throw $pstat;
            }

            // check to see if can access parent
            if (!$pstat['can_write'] || !$this->can_descend($parent)) {
                return error($this->unmake_path($parent) . ': permission denied');
            }

            foreach ($dir2mk as $newdir) {
                $res = \Opcenter\Filesystem::mkdir(
                    $newdir, $this->user_id, $this->group_id, $mode
                );

                if (!$res) {
                    return error('%s: cannot create directory', $this->unmake_path($newdir));
                }
            }

            return true;
        }

        /**
         * string unmake_path(string $mPath)
         * Complimentary function to make_path
         *
         * @param string $path
         * @return string chroot'd path
         */
        public function unmake_path(string $path): string
        {
            // admin always has root access
            if ($this->permission_level & PRIVILEGE_ADMIN) {
                return $path;
            }
            $path = str_replace('//', '/', $path);
            $offset = 0;
            if (($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) &&
                0 === strpos($path, $this->domain_fs_path()))
            {
                $offset = strlen($this->domain_fs_path());
            }

            return '/' . ltrim(substr($path, $offset), '/');
        }

        /**
         * Gives information about a file
         *
         * Peforms a stat() request on named file
         *
         *    array(22) {
         *    ["owner"]=> string(5) "debug"
         *    ["group"]=> string(5) "group"
         *    ["uid"]=> int(664)
         *    ["gid"]=> int(664)
         *    ["size"]=> int(4096)
         *    ["file_type"]=> string(3) "dir"
         *    ["referent"]=> NULL
         *    ["portable"]=> bool(true)
         *    ["link"]=> int(0)
         *    ["nlinks"]=> int(4)
         *    ["permissions"]=> int(16889)
         *    ["site_quota"]=> bool(true)
         *    ["user_quota"]=> bool(true)
         *    ["ctime"]=> int(1242769316)
         *    ["mtime"]=> int(1242769316)
         *    ["atime"]=> int(1230433552)
         *    ["inode"]=> int(2454742)
         *    ["can_write"]=> bool(true)
         *    ["can_read"]=> bool(true)
         *    ["can_execute"]=> bool(true)
         *    ["can_chown"]=> bool(true)
         *    ["can_chgrp"]=> bool(true)
         *  }
         *
         *  owner: resolved name of the uid
         *  group: resolved name of the gid
         *  uid:   numeric user id
         *  gid:   numeric group id
         *  size:  file size in bytes
         *  file_type: file type, values [dir, file, link]
         *  link: file is symbolic link, 2 if directory, 1 if file
         *  nlinks: number of hardlinks to the file
         *  permissions: file permissions encoded as integer
         *  site_quota: whether the file counts towards the site's quota
         *  user_quota: whether the file counts towards the user's quota
         *  ctime: creation time
         *  mtime: modification time
         *  atime: last access time
         *  inode: filesystem inode
         *  can_write: file has write bit set
         *  can_read:  file has read bit set
         *  can_execute: file has execute bit set
         *  can_chown: user privileged to chown()
         *  can_chgrp: user privileged to chgrp()
         *
         * @param string $file
         * @return array
         */
        public function stat($file)
        {
            return $this->query('file_stat_backend', $file, true);
        }

        private function _getCache($file)
        {
            $dir = dirname($file);
            $filename = basename($file);
            if (!isset($filename[0])) {
                $filename = '.';
            }
            $dirhash = md5($dir);
            $filehash = md5($filename);
            $siteid = $this->site_id;
            if (isset($this->stat_cache[$siteid][$dirhash][$filehash])) {
                return $this->stat_cache[$siteid][$dirhash][$filehash];
            }
            $cache = $this->cached;
            $cachekey = $this->_getCacheKey($file);
            $stat = $cache->get($cachekey);
            // direct file stat

            if ($stat) {
                $this->stat_cache[$siteid][$dirhash] = $stat;
                if (isset($stat[$filehash])) {
                    return $stat[$filehash];
                }
            }

            return false;
        }

        /**
         * bool can_descend (string)
         *
         * @param string $path      fully resolved path
         * @param bool   $rw        require write permissions as well
         * @param bool   $direxists require that directory exists
         *                          break before encountered
         */
        private function can_descend($path, $rw = false, $direxists = true)
        {

            $fspfx = $this->domain_fs_path();
            if (0 !== strpos($path, $fspfx)) {
                return error($path . ': not fully qualified path');
            }
            // directory components to examine, assume fs prefix is immutable
            $dirchk = substr($path, strlen($fspfx));
            $subdir = strtok($dirchk, '/');
            $curpath = '';
            do {
                $curpath .= '/' . $subdir;
                $fullpath = $fspfx . $curpath;
                if (!file_exists($fullpath)) {
                    if ($direxists) {
                        return false;
                    } else {
                        break;
                    }
                }
                if (is_link($fullpath)) {
                    $fullpath = \realpath($fullpath);
                    if (0 !== strpos($fullpath, $fspfx)) {
                        return error('Corrupted path detected');
                    }
                }
                $stat = $this->stat_backend(substr($fullpath, strlen($fspfx)));
                if ($stat instanceof Exception) {
                    return error($stat->getMessage());
                }
                if (!$stat['can_execute'] || !$stat['can_read']) {
                    return false;
                }
            } while (false !== ($subdir = strtok('/')));

            if ($rw && !$stat['can_write']) {
                return false;
            }


            return true;
        }

        /**
         * Calculate etag of a file
         *
         * @param string $file
         * @return null|string
         */
        public function etag($file)
        {
            $stat = $this->file_stat($file);
            if (!$stat) {
                return null;
            }

            return sha1($stat['inode'] . $stat['size'] . $stat['mtime']);
        }

        /**
         * Make a protected file ephemerally accessible by apnscp
         *
         * @xxx dangerous
         * File is removed on script end
         *
         * @param string $file
         * @param string $mode read or write
         * @return string file
         */
        public function expose($file, $mode = 'read')
        {
            if (!IS_CLI) {
                $clone = $this->query('file_expose', $file, $mode);
                // always ensure this
                if ($clone) {
                    register_shutdown_function(static function ($clone, $prefix) {
                        if (file_exists($prefix . $clone)) {
                            unlink($prefix . $clone);
                        }
                    }, $clone, $this->domain_fs_path());
                }

                return $clone;
            }

            if ($mode !== 'read' && $mode !== 'write') {
                return error("unknown mode `%s'", $mode);
            }

            $stat = $this->stat_backend($file);
            if (!$stat['can_' . $mode]) {
                return error("cannot access file `%s'", $file);
            } else if ($stat['file_type'] !== 'file') {
                return error("file `%s' is not a regular file", $file);
            } else if ($stat['nlinks'] > 1) {
                return error("file `%s' must not be linked elsewhere", $file);
            }
            $tmppath = $this->make_path(TEMP_DIR);
            $tempnam = tempnam($tmppath, 'ex');
            unlink($tempnam);
            $path = $this->make_path($file);
            link($path, $tempnam);
            if ($stat['inode'] !== fileinode($tempnam)) {
                error("possible race condition, expected ino `%d', got `%d' - removing `%s'",
                    $stat['inode'], fileinode($tempnam), $tempnam);
                unlink($tempnam);

                return false;
            }
            chown($tempnam, WS_UID);
            clearstatcache(true, $path);
            $this->_purgeCache($file);

            return $this->unmake_path($tempnam);
        }

        /**
         * Remove files from cache
         *
         * @param array|string $files
         * @return bool
         */
        private function _purgeCache($files)
        {
            $purged = array();
            $siteid = $this->site_id;
            $path = $this->domain_fs_path();
            foreach ((array)$files as $f) {
                $dir = dirname($f);
                $hash = md5($dir);
                clearstatcache(true, $path . $f);
                $this->trans_paths[$this->site_id][$f] = null;
                if (isset($purged[$hash])) {
                    continue;
                }
                $this->stat_cache[$siteid][$hash] = null;
                $this->cached->delete('s:' . $hash);
                $purged[$hash] = 1;
            }
            if (count($purged) > 1) {
                $this->clearstat = true;
            }

            return true;
        }

        /**
         * List contents of a compressed file
         *
         * @param  string $file file name
         * @return array
         */
        public function get_archive_contents($file)
        {
            if (!IS_CLI) {
                return $this->query('file_get_archive_contents', $file);
            }

            $path = $this->make_path($file);
            $stat = $this->stat_backend($file);

            if ($path instanceof Exception) {
                return $path;
            }
            if ($stat instanceof Exception) {
                return $stat;
            }

            $class = $this->initialize_interface($path);
            if ($class instanceof Exception || !$class) {
                return $class;
            }

            $files = $class->list_files($path);
            Util_Conf::sort_files($files, 'value', true);

            return $files;
        }

        /**
         * Copy files
         *
         * @param  string|array $source    array of source files or directories to copy
         * @param  string       $dest      destination directory or file
         * @param  int          $force     overwrite destination with source if exists
         * @param  int          $recursive recursively copy directory contents
         * @param  int          $prune     remove target before copying
         * @return bool
         */
        public function copy($source, $dest, $force = true, $recursive = true, $prune = false)
        {
            if (!IS_CLI) {
                if (!$source || !$dest) {
                    return error('invalid source or destination');
                }
                $res = $this->query('file_copy', $source, $dest, $force, $recursive, $prune);
                if ($res) {
                    $this->_purgeCache($source);
                    $this->_purgeCache($dest);
                }

                return $res;
            }
            if ($this->permission_level & PRIVILEGE_SITE) {
                $optimized = $this->_optimizedShadowAssertion;
            } else {
                $optimized = false;
            }

            if (!is_array($source)) {
                $source = array($source);
            }

            if ($optimized && $optimized !== 2) {
                // only copy off shadow, if in the future ro -> rw branch
                // propagation can setgid copy-ups, then maybe switch back to make_path()
                // do not use shadow path as dest if luna+ (OverlayFS)
                $dest_path = $this->make_shadow_path($dest);
            } else {
                $dest_path = $this->make_path($dest);
            }

            if (\Util_PHP::is_link($dest_path)) {
                $dest_path = readlink($dest_path);

            }
            $dest_parent = $dest_path;

            // destination is not a folder
            if (!file_exists($dest_path) || !is_dir($dest_path)) {
                if (count($source) > 1) {
                    return error('copying mulitple files, but ' .
                        "destination `$dest' is not a directory");
                }
                $dest_parent = dirname($dest_path);
            }
            if (!file_exists($dest_parent)) {
                return error("destination `$dest_parent' does not exist");
            }

            // perform check on target to prevent copying over a dangerous .bashrc into /root
            // in a provisioned environment this shouldn't be the case; /root isn't setup
            $parent_stat = $this->stat_backend($this->unmake_path($dest_parent));
            if ($parent_stat instanceof Exception) {
                return $parent_stat;
            }
            if (!$parent_stat['can_write'] || !$parent_stat['can_execute'] || !$parent_stat['can_read']) {
                return error("accessing `$dest': permission denied");
            }

            $files_copied = -1; // number of files copied
            for ($i = 0, $nsource = sizeof($source); $i < $nsource; $i++) {
                $link = '';
                $src_path = $optimized ? $this->make_shadow_path($source[$i], $link) :
                    $this->make_path($source[$i], $link);

                if (strlen($source[$i]) <= 6) {
                    return error('aborting operation for your own good! ' . var_export($source[$i]));
                }

                if ($link) {
                    $files = (array)$link;
                } else if ($src_path === $dest_path) {
                    warn('source directory `' .
                        $this->unmake_path($src_path) . "' and destination are same");
                    continue;
                } else {
                    $files = glob($src_path, GLOB_NOSORT);
                }

                for ($j = 0, $nfiles = sizeof($files); $j < $nfiles; $j++) {
                    $file = $files[$j];
                    if (!file_exists($file)) {
                        continue;
                    }

                    if ($optimized) {
                        $local_file = $this->unmake_shadow_path($file);
                        $perms = fileperms($file);
                        // assume if g/o lack read (4) then it's protected
                        if (!($perms & ~0x3FFDB) && fileowner($file) < \User_Module::MIN_UID) {
                            $files_copied = 0;
                            error("cannot read `$local_file'");
                            continue;
                        }
                        // create a stat array with bare minimums
                        $fstat = array(
                            'file_type'   => filetype($file),
                            'permissions' => $perms
                        );
                    } else {
                        $local_file = $this->unmake_path($file);
                        $fstat = $this->stat($local_file);
                        #fwrite(STDERR, "F: (".$fstat['file_type'].")".$file."\n");
                        if ($fstat instanceof Exception) {
                            return $fstat;
                        }
                        if (!$fstat['can_read']) {
                            $files_copied = 0;
                            error("cannot read `$local_file'");
                            continue;
                        }
                    }

                    if ($fstat['file_type'] != 'dir') {
                        // copy file to newfile
                        $newfile = $dest_path;
                        if ($dest_parent == $dest_path) {
                            $newfile .= '/' . basename($local_file);
                        }
                        if (is_dir($newfile)) {
                            //fwrite(STDERR, "WTF\n\n\n");
                            $newfile .= basename($local_file);
                        }
                        if ($file === $newfile) {
                            warn('source `' . basename($file) . "' destination same");
                            continue;
                        }
                        if (file_exists($newfile)) {
                            if (!$force) {
                                warn('cannot overwrite `' . $this->unmake_path($newfile) . ' ' .
                                    $dest_parent . ' ' . $dest_path . ' ' . $local_file . "'");
                                $files_copied = 0;
                                continue;
                            }

                            if (fileinode($newfile) === fileinode($file)) {
                                warn('%s is same file - skipping', $this->unmake_path($newfile));
                                $files_copied = 0;
                                continue;
                            }
                            // permissions are already asserted by checking dest
                            unlink($newfile);
                        }
                        #fwrite(STDERR, "Copy $file to $newfile\n");
                        copy($file, $newfile) && chown($newfile, $this->user_id) &&
                        chgrp($newfile, $this->group_id) and $files_copied &= 1;
                        clearstatcache(true, $newfile);
                        continue;
                    } else {
                        if (!$recursive) {
                            warn("skipping directory `$local_file");
                            $files_copied = 0;
                            continue;
                        }
                        // copy directory contents
                        $mkdir = '';
                        $newdest = $dest . ($file[-1] === '/' ? '' : ('/' . basename($local_file)));
                        // directory rename
                        if (!file_exists($dest_path)) {
                            $mkdir = 1;
                            $newdest = $dest;
                            // directory parenting
                        } else {
                            if (!file_exists($dest_path) . '/' . basename($local_file)) {
                                $mkdir = 1;
                            }
                        }
                        //print "Dir - ".$dest_path." -- ".$newdest." -- ".$mkdir."\n";
                        if ($mkdir) {
                            if (!$this->create_directory($newdest, $fstat['permissions'], false)) {
                                continue;
                            }
                            $files_copied &= 1;
                        }
                        //fwrite(STDERR, "Enumerating dir $local_file to $newdest\n");
                        $subreq = $this->copy(
                            array($local_file . '/*'),
                            $newdest,
                            $force,
                            $recursive,
                            $prune
                        );
                        if ($prune && !$subreq) {
                            $this->delete($dest, true);
                        }
                        $files_copied &= $subreq;

                    }
                }
            }

            return (bool)$files_copied;
        }

        /**
         * Strip shadow prefix from path
         *
         * Because this is a wrapper to unmake_path,
         *
         * @param $path
         * @return string
         */
        public function unmake_shadow_path($path)
        {
            $shadow = $this->domain_shadow_path();

            if (0 === strpos($path, $shadow)) {
                $fst = $this->domain_fs_path();
                $path = $fst . substr($path, strlen($shadow));
            }

            return $path = $this->unmake_path($path);
        }

        /**
         * bool delete (mixed, [bool = FALSE])
         * Deletes a file from within the filesystem and calls {@see can_delete()}
         *
         * @param mixed $file      accepts file or array of files to delete
         * @param  bool $recursive recusrively delete files
         * @return bool|FileError
         */
        public function delete($file, $recursive = false)
        {
            if (!is_array($file)) {
                $file = array($file);
            }
            foreach ($file as $locfile) {
                if (!self::sanitized($locfile)) {
                    return error('Junk path detected for %s', $locfile);
                }
            }
            $data = $this->query('file_delete_backend', $file, (bool)$recursive);
            $this->_purgeCache($file);

            if (is_array($data)) {
                throw new FileError(implode("\n", $data));
            }

            return true;
        }

        /**
         * bool delete_backend (mixed, [bool = FALSE])
         *
         * @see delete()
         *
         * @param array $files   files to remove
         * @param bool  $recurse recurse into directories
         * @param int   $depth   current depth
         * @return bool
         */
        public function delete_backend(array $files, $recurse, $depth = 1)
        {
            // @var int return value
            $ret = 1;
            // @var int
            $ok = 1;
            // @var int length to truncate
            $truncate = 0;
            $optimized = $this->_optimizedShadowAssertion &&
                ($this->permission_level & PRIVILEGE_SITE);

            if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
                $truncate = strlen($this->domain_fs_path());
            }
            $shadow = $optimized ? $this->domain_shadow_path() : null;
            foreach ($files as $wcfile) {
                // at least something under /etc
                if (!isset($wcfile[5])) {
                    \Error_Reporter::report('Critical file error - IN:' . var_export($files, true) .
                        "\n\nOUT:" . var_export($wcfile, true));
                    fatal("Something's wrong, aborting! ");
                }
                $link = '';
                $exdir = $this->make_path($wcfile, $link);

                if ($link) {
                    // do not follow symlinks
                    $exdir = $link;
                }

                if (!$exdir) {
                    continue;
                } else if ($depth > 1 || \Util_PHP::is_link($exdir)) {
                    // prevent glob("foo" -> ../../tmp);
                    $globmatch = array($exdir);
                } else {
                    $globmatch = glob($exdir, GLOB_NOSORT);
                }

                for ($i = 0, $n = count($globmatch); $i < $n;
                     $i++, $ret &= $ok) {
                    $ok = 0;
                    $rmpath = $chkpath = $globmatch[$i];
                    $file = $rmpath;

                    // local file name
                    if ($truncate) {
                        $file = substr($globmatch[$i], $truncate);
                    }
                    // check file on shadow instead of fst/
                    if ($optimized) {
                        $chkpath = $shadow . $file;
                    }

                    $is_link = \Util_PHP::is_link($chkpath);
                    // file outside truncate path or chkpath does not exist/is not link
                    if (!$file || (!file_exists($chkpath) && !$is_link)) {
                        $ok = 1;
                        continue;
                    }
                    if (!$optimized) {
                        // perform stat only on secondary users or old platforms
                        $stat = $this->stat(dirname($file));
                        if ($stat instanceof Exception) {
                            \Error_Reporter::handle_exception($stat);
                            error($file . ': cannot delete- stat failed');
                            continue;
                        }
                        /**
                         * let OSA obliterate symlinks if they want to on their layer, only do stat check on non-OSA
                         */
                        if (!$stat['can_execute'] || !$stat['can_write']) {
                            warn($file . ': cannot remove directory- permission denied');
                            continue;
                        }
                    }

                    $is_dir = !$is_link && is_dir($chkpath);
                    // file is a directory, enumerate its children, delete,
                    // and if successful (directory is non-empty), remove this
                    // directory
                    if (!$is_link && $is_dir) {
                        if (!$recurse) {
                            warn($file . ': cannot remove directory without ' .
                                'recursive option');
                            continue;
                        }
                        clearstatcache(true, $rmpath);
                        $dh = opendir($rmpath);
                        if (!$dh) {
                            error($file . ': cannot open directory');
                            continue;
                        }

                        $dirfiles = array();
                        while (false !== ($dirent = readdir($dh))) {
                            if ($dirent === '.' || $dirent === '..') {
                                continue;
                            }
                            $tmp = "${rmpath}/${dirent}";
                            if (\is_link($tmp) || \is_file($tmp)) {
                                unlink($tmp);
                                clearstatcache(true, $tmp);
                            } else {
                                $dirfiles[] = "${file}/${dirent}";
                            }
                        }
                        closedir($dh);
                        $ok = $this->delete_backend($dirfiles, $recurse, $depth + 1);
                        // cannot remove directory if subdir has files
                        if (!$ok) {
                            continue;
                        }
                    }

                    if ((($is_link || !$is_dir) && !unlink($rmpath)) ||
                        ($is_dir === true && !rmdir($rmpath))
                    ) {
                        $errmsg = Error_Reporter::get_last_php_msg();
                        if ($errmsg) {
                            warn('%s: cannot remove- %s', $file, $errmsg);
                        }
                        continue;
                    }

                    $ok = 1;
                }

                // ensure translated path is cleaned up
                $this->trans_paths[$this->site_id][$wcfile] = null;
            }

            return (bool)$ret & $ok;

        }

        /**
         * bool chown(string, string[, bool = false])
         *
         * @param mixed      $mFile      array of filenames or single filename
         * @param int|string $mUser      target uid or username
         * @param bool       $mRecursive recursively chown
         * @return bool
         */
        public function chown($mFile, $mUser, $mRecursive = false)
        {
            if (!IS_CLI) {
                $ret = $this->query('file_chown', $mFile, $mUser, $mRecursive);
                $this->_purgeCache($mFile);
                $this->purge(false);

                return $ret;
            }
            $validUsers = array_keys($this->user_get_users());
            $validUsers[] = \Web_Module::WEB_USERNAME;

            if ($this->tomcat_enabled()) {
                $validUsers[] = $this->tomcat_system_user();
            }
            if (is_int($mUser)) {
                $mUser = $this->user_get_username_from_uid($mUser);
            }
            if (!in_array($mUser, $validUsers, true)) {
                return error('invalid user `' . $mUser . "'");
            } else if (!is_array($mFile)) {
                $mFile = array($mFile);
            }
            $errors = array();
            $tUID = $this->user_get_users();

            // RHEL constant UID
            $tUID[\Web_Module::WEB_USERNAME] = array('uid' => APACHE_UID);
            if (!isset($tUID[$mUser]['uid'])) {
                return error('Eep, unable to find UID for ' . $mUser);
            }
            $tUID = (int)$tUID[$mUser]['uid'];

            foreach ($mFile as $file) {
                $link = null;
                $path = $this->make_shadow_path($file, $link);
                // OSA on link, resolve path
                if ($link) {
                    $path = $this->make_path($file);
                    $stat = $this->stat_backend($file);

                    if ($path instanceof Exception) {
                        $errors[$file] = $path->getMessage();
                        continue;
                    }
                    if ($stat instanceof Exception) {
                        $errors[$file] = $stat->getMessage();
                        continue;
                    }
                    if (!$this->can_descend(dirname($path))) {
                        $errors[$file] = 'insufficient permissions to access';
                        continue;
                    }
                    if (!$link && !$stat['can_chown']) {
                        $errors[$file] = 'Unable to change ownership of ' . $file;
                        continue;
                    }
                }

                if (!$link && $mRecursive && is_dir($path)) {
                    // Recursive chown
                    $files = \Opcenter\Filesystem::readdir($path, static function ($item) use ($file) {
                        return "$file/$item";
                    });
                    if ($files === false) {
                        $errors[$file] = 'failed to open directory';
                        continue;
                    }
                    $status = $this->chown($files, $mUser, $mRecursive);
                    if ($status instanceof Exception) {
                        $errors[$file] = $status->getMessage();
                    }
                } else if ($link) {
                    // symlink
                    warn('%s is a link, treating as symlink', $file);
                    if (!$this->chown_symlink($file, $mUser)) {
                        $errors[$file] = \Error_Reporter::get_last_php_msg();
                    }
                }

                if (!$link && !chown($path, $tUID)) {
                    // regular file
                    $errors[$file] = Error_Reporter::get_last_php_msg();
                }
            }
            $this->_purgeCache($mFile);

            if (count($errors)) {
                throw new FileError(implode("\n", $errors));
            }

            return true;
        }

        /**
         * Dump OverlayFS cache
         *
         * @return bool
         */
        public function purge(bool $full = true)
        {
            if ($this->permission_level & !(PRIVILEGE_SITE | PRIVILEGE_USER)) {
                return true;
            }

            if (!IS_CLI) {
                $this->stat_cache[$this->site_id] = [];
                return $this->query('file_purge', $full);
            }

            if (!$full) {
                return true;
            }

            $proc = Util_Process::exec(\Opcenter\Service\ServiceLayer::MOUNT_CMD . ' reload_site %s', $this->site);
            return $proc['success'];
        }

        /**
         * bool chgrp(string, string[, bool = false])
         *
         * @param string $mFile      filename
         * @param string $mGroup     target gid, effectively just the admin's uid
         * @param bool   $mRecursive recursively chown
         * @return bool
         */
        public function chgrp($mFile, $mGroup, $mRecursive = false)
        {
            // @XXX why is this even here?
            if (!IS_CLI) {
                return $this->query('file_chgrp', $mFile, $mGroup, $mRecursive);
            }
            $admin = $this->group_id;
            foreach ($this->common_get_users() as $user => $data) {
                if ($data['gid'] == $data['uid']) {
                    $admin = $user;
                }
            }
            if ($mGroup != $admin) {
                return error('invalid group `' . $mGroup . "'");
            } else if (!is_array($mFile)) {
                $mFile = array($mFile);
            }
            $errors = array();

            if ($this->permission_level & PRIVILEGE_SITE) {
                $optimized = $this->_optimizedShadowAssertion;
            } else {
                $optimized = false;
            }
            foreach ((array)$mFile as $file) {
                if ($optimized) {
                    $path = $this->make_shadow_path($file);
                } else {
                    $path = $this->make_path($file);
                    $stat = $this->stat_backend($file);
                    if ($path instanceof Exception) {
                        $errors[$file] = $path->getMessage();
                        continue;
                    } else if ($stat instanceof Exception) {
                        $errors[$file] = $stat->getMessage();
                        continue;
                    } else if (!$this->can_descend(dirname($path))) {
                        $errors[$file] = 'insufficient permissions to access';
                        continue;
                    } else if (!$stat['can_chgrp']) {
                        $errors[$file] = 'Unable to change group ownership of ' . $file;
                        continue;
                    }
                }

                if ($mRecursive && is_dir($path)) {
                    // Recursive chown
                    $files = \Opcenter\Filesystem::readdir($path, static function ($item) use ($file) {
                        return "$file/$item";
                    });
                    if ($files === false) {
                        $errors[$file] = 'failed to open directory';
                        continue;
                    }
                    $status = $this->chgrp($files, $mGroup, $mRecursive);
                    if ($status instanceof Exception) {
                        $errors[$file] = $status->getMessage();
                        continue;
                    }
                }
                if (is_link($path)) {
                    warn('File %s is not regular file, treating as symlink', $file);
                    \Util_PHP::lchgrp($path, $mGroup);
                } else if (!chgrp($path, $mGroup)) {
                    $errors[$file] = Error_Reporter::get_last_php_msg();
                }
            }

            return (sizeof($errors) == 0 ? true : $errors);
        }

        /**
         * bool chmod (string, int)
         *
         * @param string $mFile file name
         * @param int    $mMode mode in octal for the file
         * @param bool   $mRecursive
         */
        public function chmod($mFile, $mMode, $mRecursive = false)
        {
            if (!ctype_digit($mMode)) {
                return error('invalid mode');
            }

            $ret = $this->query('file_chmod_backend', $mFile, $mMode, $mRecursive);
            $this->_purgeCache($mFile);

            return $ret;
        }

        /**
         * bool chmod_backend (string, int[, bool = false])
         * {@link chmod}
         */
        public function chmod_backend($mFile, $mMode, $mRecursive)
        {
            if (!is_float($mMode) && (strlen((string)$mMode) != 4)) {
                $mMode = (float)octdec('0' . (string)$mMode);
            } else if (!is_float($mMode)) {
                $mMode = (float)octdec($mMode);
            }
            $mMode = (int)$mMode;
            /* 4095 dec -> 7777 oct
             * 0140000 -> socket
             * 0147777 -> socket + all perms
             */
            if ($mMode > 0xCFFF) {
                // 0147777
                return error("invalid mode `%o'", $mMode);
            }
            $purge = (array)$mFile;
            $path = $this->make_path($mFile, $link);
            if ($path instanceof Exception) {
                return $path;
            } else if ($link) {
                $newpath = $this->unmake_path($path);
                if ($newpath === $mFile) {
                    return error("`%s': irresolvable symlink", $newpath);
                }
                return $this->chmod_backend($newpath, $mMode, $mRecursive);
            }
            if ($mRecursive && is_dir($path)) {

                $files = \Opcenter\Filesystem::readdir($path);
                if ($files === false) {
                    return false;
                }

                foreach ($files as $file) {
                    $file = $mFile . '/' . $file;
                    $stat = $this->stat_backend($file);
                    if ($stat['link']) {
                        continue;
                    }
                    if ($stat instanceof Exception) {
                        error($stat->getMessage());
                        continue;
                    }
                    if (!$stat['can_chown']) {
                        warn('cannot chmod perm denied: ' . $file);
                        continue;
                    }
                    $purge[] = $file;

                    if ($stat['file_type'] == '\dir') {
                        $this->chmod_backend($file, $mMode, $mRecursive);
                    } else {
                        chmod($this->make_path($file), $mMode);
                    }
                }
            }
            $stat = $this->stat_backend($mFile);
            if ($stat instanceof Exception) {
                return warn($stat->getMessage());
            }
            if (!$stat['can_chown']) {
                return warn('cannot chmod perm denied: ' . $mFile);
            }

            $ret = chmod($path, (int)$mMode);
            $this->_purgeCache($purge);

            return $ret;
        }

        /**
         * Determines the MIME type of a file through the file shell command
         *
         * @param string $file
         * @return string|null mime type
         *
         */
        public function get_mime_type($file): ?string
        {
            $path = $this->make_path($file);
            if (!IS_CLI) {
                if (!$path || ($path instanceof Exception) || !file_exists($path) || !is_readable($path)) {
                    return $this->query('file_get_mime_type', $file);
                }

                return mime_content_type($path) ?: null;
            }

            $stat = $this->stat($file);
            if ((!$stat || !$stat['can_read']) || ($stat['link'] && null === $stat['referent'])) {
                return null;
            }

            return mime_content_type($path) ?: null;
        }

        /**
         * Get file contents
         *
         * @param string $mPath path to the filename
         * @param  bool  $raw   base64 encode data
         * @return string
         *
         */
        public function get_file_contents($mPath, $raw = true)
        {
            $path = $this->make_path($mPath);

            if ($path instanceof Exception) {
                return $path;
            }

            if (!is_readable($path)) {
                return $this->query('file_get_file_contents_backend', $mPath, $raw);
            }

            if (!is_file($path)) {
                return error($mPath . ' is not a file');
            }

            return !$raw ? base64_encode(file_get_contents($path)) : file_get_contents($path);


        }
        /* }}} */

        /**
         * @see get_file_contents()
         * @param  string $mPath file name
         * @param  bool   $mRaw
         * @return string base64-encoded file
         */
        public function get_file_contents_backend($mPath, $mRaw = true)
        {
            $path = $this->make_path($mPath);
            if ($path instanceof Exception) {
                return $path;
            } else if (!is_file($path)) {
                return new FileError($mPath . ' is not a file');
            }
            if (!is_readable($path)) {
                return error('Unable to read ' . $mPath);
            }
            if (!$this->_assert_permissions($mPath, 'read')) {
                return error('Unable to read file');
            }
            $stat = $this->stat($mPath);
            if (!$stat || !$stat['can_read']) {
                return error('Unable to read file ' . $mPath);
            }
            $str = file_get_contents($path);

            return ($mRaw ? $str : base64_encode($str));

        }

        /**
         * Verify user is privileged for file
         *
         * @param string $mFile     file name
         * @param string $mPermType perm type, may be either read write or execute
         * @todo  update code for write, execute
         */
        private function _assert_permissions($mFile, $mPermType)
        {
            $stats = $this->stat_backend($mFile);
            if ($stats instanceof Exception) {
                return false;
            }

            switch ($mPermType) {
                case 'read':
                    if (($this->permission_level & PRIVILEGE_SITE)) {
                        return true;
                    }
                case 'write':
                    return true;
                case 'execute':
                    return true;
                default:
                    return false;
            }
        }
        /* }}} */

        /**
         * Write contents to file
         *
         * @param  string $path      path to the filename
         * @param  string $data      file data
         * @param  bool   $overwrite if the file exists, overwrite
         * @param  bool   $binary    data is to be treated as binary data. Data must be base64 encoded
         * @return bool
         *
         * @privilege PRIVILEGE_ALL
         */
        public function put_file_contents($file, $data, $overwrite = true, $binary = false)
        {
            return $this->query('file_put_file_contents_backend', $file, $data, (bool)$overwrite, (bool)$binary);
        }

        /**
         * @see put_file_contents()
         */
        public function put_file_contents_backend($mFile, $mData, $mOverwrite, $binary)
        {
            $path = $this->make_path($mFile);
            if ($path instanceof Exception) {
                return $path;
            }
            $dir_stat = $this->stat_backend(dirname($mFile));

            if ($dir_stat instanceof Exception) {
                return $dir_stat;
            }

            if (file_exists($path)) {
                $file_stat = $this->stat_backend($mFile);
                if ($file_stat instanceof Exception) {
                    return $file_stat;
                }
            }

            if (!file_exists($path) && (!$dir_stat['can_write'])) {
                return error('Cannot write to destination directory ' . dirname($mFile));
            } else if ($binary && !preg_match('/^[a-zA-Z0-9\+\/=]*$/', $mData)) {
                return new ArgumentError('File data not base64 encoded');
            }

            if (file_exists($path)) {
                if (!$mOverwrite) {
                    return new FileError('Target ' . $mFile . ' already exists');
                } else {
                    if ($mOverwrite && !is_file($path)) {
                        return new FileError('Target ' . $mFile . ' is not a file');
                    } else {
                        if (!$file_stat['can_write']) {
                            return error('Cannot overwrite file');
                        }
                    }
                }
            }

            if (!file_exists($path) &&
                ($status = $this->create_file($mFile, 0644)) instanceof Exception
            ) {
                return $status;
            }

            if (!$fp = fopen($path, 'w' . ($binary ? '' : 'b'))) {
                return error("Failed to open `%s'", $mFile);
            }
            fwrite($fp, !$binary ? $mData : base64_decode($mData));
            fclose($fp);
            $this->_purgeCache((array)$mFile);

            return true;
        }

        /**
         * Create an empty file
         *
         * @param string $file file name to create
         * @param int    $mode mode for the file
         * @return bool|\Exception
         */
        public function create_file(string $file, $mode = 0644)
        {
            if (!IS_CLI) {
                return $this->query('file_create_file', $file, $mode);
            }

            $path = $this->make_path($file);
            if ($path instanceof Exception) {
                return $path;
            }
            $stat = $this->stat(dirname($file));

            if ($stat instanceof Exception || !$stat) {
                return $stat;
            }

            if (!$stat['can_write']) {
                return error(dirname($file) . ': cannot write to directory');
            }
            if (file_exists($path)) {
                return error($file . ': file exists');
            } else if (is_link($path)) {
                return error($file . ': is link');
            }
            $fp = fopen($path, 'w');
            fclose($fp);
            chown($path, (int)$this->user_id);
            chgrp($path, (int)$this->group_id);
            chmod($path, $mode);

            return true;
        }

        /**
         * array get_directory_contents (string)
         *
         * @param string $mPath the path to the directory
         * @return array
         */
        public function get_directory_contents($mPath, $sort = true)
        {
            return $this->query('file_get_directory_contents_backend', rtrim($mPath, '/'), $sort, true);
        }

        public function get_directory_contents_backend($mPath, $sort = true, $shadow = false)
        {
            $path = $shadow ? $this->make_shadow_path($mPath) : $this->make_path($mPath);
            if ($path instanceof Exception) {
                return $path;
            }
            if (!is_dir($path)) {
                return error("`%s': invalid directory", $mPath);
            }
            // trust transformed path, e.g. get_directory_contents("~/")
            $mPath = rtrim($shadow ? $this->unmake_shadow_path($path) : $this->unmake_path($path), '/');
            $stat = $this->stat_backend($this->unmake_shadow_path($path));

            if ($stat instanceof Exception) {
                throw $stat;
            }

            if (!$stat['can_execute'] || !$stat['can_read']) {
                return error("cannot access directory `%s' permission denied",
                    $mPath);
            }
            if ($stat['link']) {
                $mPath = $stat['referent'];
            }
            $dirHandle = dir($path);
            if (!$dirHandle) {
                return error(__FUNCTION__ . '(): unable to access directory');
            }
            $files = array();

            while (false !== ($entry = $dirHandle->read())) {
                /** chroot'd passwd file */
                if ($entry == '.' || $entry == '..') {
                    continue;
                }
                $stat = $this->stat($mPath . '/' . $entry);
                if ($stat instanceof Exception) {
                    return $stat;
                }
                if (!isset($stat['owner'])) {
                    /**
                     * dir() suffers from a delayed cache response
                     * confirm stat finds the file on the fs
                     */
                    continue;
                }
                $stat['file_name'] = $mPath . '/' . $entry;

                if ($sort) {
                    $files[] = $stat;
                } else {
                    $files[$mPath . '/' . $entry] = $stat;
                }
            }
            unset($dirHandle);

            if ($sort) {
                Util_Conf::sort_files($files);
            } else {
                Util_Conf::sort_files($files, 'key');
            }

            return $files;
        }

        /**
         * Make directories writeable by Web server for use with DAV
         *
         * @param string|array $paths     paths
         * @param bool         $recursive recursively change ownerhsip
         *
         * Calls setgid, fixes permissions for Apache-written directories
         *
         * @return bool
         */
        public function fix_apache_perms_backend($paths, $recursive = false)
        {
            if (!is_array($paths)) {
                $paths = array($paths);
            }
            $prefix = $this->domain_fs_path();
            if (version_compare(platform_version(), '4.5', '>=')) {
                $prefix = $this->domain_shadow_path();
            }

            foreach ($paths as $path) {
                $path_resolved = $prefix . '/' . $path;
                if (!file_exists($path_resolved)) {
                    error("`$path': invalid path");
                    continue;
                }
                $stat = $this->file_stat($path);
                $uid = $stat['uid'];

                chgrp($path_resolved, (int)$this->group_id);
                $safe_path = escapeshellarg($path_resolved);

                // chown path to apache:gid
                Util_Process::exec('chown -h%s %s:%s %s',
                    ($recursive ? 'R' : ''),
                    \Web_Module::WEB_USERNAME,
                    $this->group_id,
                    $safe_path
                );

                // set ACLs on existing files
                $limit = !$recursive ? '-maxdepth 0' : '';
                $def_cmd = ' -d -m user:%5$s:%2$s -d -m user:%4$s:%2$s';
                $cmd = 'chmod u=+%2$s,g=+%3$s "{}" ; ' .
                    'setfacl -m user:%4$s:%2$s -m user:%5$s:%2$s';
                Util_Process::exec('find %1$s ' . $limit . ' -type d -print0 |  ' .
                    'xargs -0 -i /bin/sh -c \'' . $cmd . $def_cmd . ' "{}"\'',
                    $safe_path,
                    'rwx',
                    'rwxs',
                    $uid,
                    \Web_Module::WEB_USERNAME

                );
                $status = Util_Process::exec('find %1$s ' . $limit . ' -type f -print0 | ' .
                    'xargs -0 -i /bin/sh -c \'' . $cmd . ' "{}"\'',
                    $safe_path,
                    'rw',
                    'rw',
                    $uid,
                    \Web_Module::WEB_USERNAME
                );
            }

            return $status['success'];
        }

        /**
         * Locate files under a given path matching requirements
         *
         * Requirements:
         *    - user: username
         *  - perm: permissions
         *  - mtime/ctime: modification/creation time.
         *        n > 0, more than n days ago, n < 0, less than n days ago
         *  - name/regex: filename glob/regex match
         *
         * @param string $path
         * @param array  $requirements optional requirements
         * @param bool   $union        all must match
         * @return array|bool false on error
         */
        public function audit(string $path, array $requirements = [], bool $union = true)
        {
            if (!IS_CLI) {
                return $this->query('file_audit', $path, $requirements, $union);
            }
            // @TODO convert to Opcenter class, nasty
            if (!$requirements) {
                $webuser = $this->web_get_user($path);
                $requirements = ['user' => $webuser];
            }
            $recognized = ['user', 'perm', 'mtime', 'ctime', 'regex', 'name'];
            if ($bad = array_except($requirements, $recognized)) {
                return error("Unrecognized audit options: `%s'", implode(',', $bad));
            }
            if (!$fspath = $this->make_shadow_path($path)) {
                return error("unknown path `%s'", $path);
            }
            if (!$stat = $this->stat($path)) {
                return error("failed to stat `%s'", $path);
            }
            if (!$stat['file_type'] === 'dir' || !$stat['can_execute']) {
                return error("path `%s' is not a directory or cannot access", $path);
            }
            $cmdstr = 'find %(path)s';
            $cmds = [];
            $cmdargs = ['path' => $fspath];

            if (isset($requirements['perm'])) {
                $cmds[] = '-perm %(perm)s';
                if (($idx = strspn((string)$requirements['perm'],
                        '012345678gwox+-r')) !== \strlen((string)$requirements['perm'])) {
                    return error("Permissions must be in octal or symbolic. Invalid characters found pos %d: `%s'",
                        $idx,
                        substr((string)$requirements['perm'], $idx)
                    );
                }
                $cmdargs['perm'] = (string)$requirements['perm'];
            }
            if (isset($requirements['user'])) {
                $cmds[] = '-user %(user)s';
                if ($requirements['user'][0] === '&' || $requirements['user'][0] === '|') {
                    $cmdstr .= ' -o ';
                    $requirements['user'] = substr($requirements['user'], 1);
                }
                if (!$this->user_exists($requirements['user']) && !\array_key_exists($requirements['user'], $this->permittedUsers())) {
                    return error("Unknown user `%s'", $requirements['user']);
                }
                $cmdargs['user'] = $requirements['user'];
            }
            foreach (['ctime', 'mtime'] as $spec) {
                if (!isset($requirements[$spec])) {
                    continue;
                }
                // @todo deep type conversion in SOAP
                if ((int)$requirements[$spec] != $requirements[$spec]) {
                    return error("%s must be numeric, got `%s'", $spec, $requirements[$spec]);
                }

                $cmds[] = "-${spec} %(${spec})d";
                $cmdargs[$spec] = $requirements[$spec];
            }
            if (isset($requirements['name'], $requirements['regex'])) {
                return error('Both name and regex cannot be specified');
            }
            foreach (['name', 'regex'] as $spec) {
                if (!isset($requirements[$spec])) {
                    continue;
                }
                $cmds[] = "-${spec} %(${spec})s";
                $cmdargs[$spec] = $requirements[$spec];
                break;
            }

            $ret = \Util_Process_Safe::exec($cmdstr . ' \( ' . implode($union ? ' ' : ' -o ',
                    $cmds) . ' \) -printf "%%P\n"', $cmdargs);
            if (!$ret['success']) {
                return error("failed to locate files under `%s': %s", $path, $ret['stderr']);
            }

            return !$ret['stdout'] ? [] : explode("\n", rtrim($ret['stdout']));
        }

        /**
         * Create a map of permitted users + system id
         *
         * @return array
         */
        private function permittedUsers(): array
        {
            // don't worry about caching, already done in user_get_users
            $uuidmap = [
                \Web_Module::WEB_USERNAME => posix_getpwnam(\Web_Module::WEB_USERNAME)['uid']
            ];

            if ($this->tomcat_permitted()) {
                $tcuser = $this->tomcat_system_user();
                $uuidmap[$tcuser] = posix_getpwnam($tcuser)['uid'];
            }
            $users = $this->user_get_users();

            return array_merge(array_combine(array_keys($users), array_column($users, 'uid')), $uuidmap);
        }

        /**
         * array report_quota (mixed)
         *
         * @param $mUIDs array of uids
         * @privilege PRIVILEGE_SITE
         * @return array associative array of quotas for given users supplied by the
         *               parameter $mUIDs.  The array is structured as follows:
         *               -uid: user id of the user N.B.: the username is not used; instead
         *               call {@link Site_Module::get_users} to retrieve the numeric values
         *               - quota:
         *               - soft:
         *               - hard
         *               - files:
         *               - file_soft:
         *               - file_hard
         */
        public function report_quota($mUIDs)
        {
            deprecated_func('use user_get_quota()');

            return null;
        }

        /**
         * Convert end-of-line characters
         *
         * @param  string $mFile   filename
         * @param  string $mTarget target platform
         * @return bool
         */
        public function convert_eol($mFile, $mTarget)
        {
            if (!IS_CLI) {
                return $this->query('file_convert_eol', $mFile, $mTarget);
            }
            $mTarget = strtolower($mTarget);
            if (!in_array($mTarget, array('unix', 'windows', 'mac'))) {
                return error('unknown platform `' . $mTarget . "'");
            }
            $stat = $this->stat($mFile);
            if (!$stat['can_read'] || !$stat['can_write']) {
                return error('cannot access `' . $mFile . "'");
            }
            $file = $this->make_path($mFile);

            $cmd = 'dos2unix';
            if ($mTarget == 'windows') {
                $cmd = 'unix2dos';
            } else if ($mTarget == 'mac') {
                $cmd = 'dos2unix -c mac';
            }

            return Util_Process_Safe::exec($cmd . ' %s',
                    $file) && chown($file, $stat['uid'])
                && chgrp($file, $stat['gid']);
        }

        /**
         * Rename a file
         *
         * @param string $file    filename
         * @param string $newfile new filename
         * @return bool
         */
        public function rename($from, $to, $files = array())
        {
            if (!IS_CLI) {
                $res = $this->query('file_rename', $from,
                    $to, $files);
                if ($res) {
                    $this->_purgeCache([$from, $to]);
                }

                return $res;
            }
            if (!is_array($files) || !$files) {
                return $this->move($from, $to);
            }

            if (!is_array($from)) {
                $file = array($from);
            }
            if (!is_array($to)) {
                $newfile = array($to);
            }
            $nsrc = sizeof($file);
            $ndest = sizeof($newfile);
            if ($nsrc > 1 && $ndest != $nsrc) {
                if ($ndest != 1) {
                    return error('cannot move files- destination ' .
                        'must be directory for multiple files');
                }

            }
            for ($i = 0, $n = sizeof($file); $i < $n; $i++) {
                if (sizeof($newfile) == 1) {
                    $newfile[$i] = $newfile[0];
                }
                if ($newfile[$i][0] != '/') {
                    $newfile[$i] = dirname($file[$i]) . '/' . $newfile[$i];
                }
            }

            $changed_ctr = 0;

            for ($i = 0, $iMax = sizeof($file); $i < $iMax; $i++) {
                $link = '';
                $src_path = $this->make_path($file[$i], $link);
                $src_stat = $this->stat_backend($file[$i]);

                $dest_path = $this->make_path($newfile[$i]);
                $dest_stat = $this->stat_backend(dirname($newfile[$i]));

                if ($dest_path instanceof Exception || $dest_stat instanceof Exception ||
                    $src_path instanceof Exception || $src_stat instanceof Exception
                ) {
                    if (file_exists($dest_path) || !$link && !file_exists($src_path)) {
                        continue;
                    }
                }
                // no perms
                if (!$link || !$dest_stat['can_execute'] && !$dest_stat['can_write']) {
                    continue;
                }

                if ($src_stat['link']) {
                    // rename won't rename a symbolic link; delete and recreate the link
                    $this->delete(array($this->unmake_path($link)), false);
                    $this->symlink($src_stat['referent'], $this->unmake_path($dest_path));
                    $this->chown_symlink($this->unmake_path($dest_path), $src_stat['owner']) && $changed_ctr++;
                } else {
                    rename($src_path, $dest_path) && $changed_ctr++;
                }

            }

            return $changed_ctr > 0;
        }

        /**
         * Move files
         *
         * @param  mixed  $src       source file(s) or directories
         * @param  string $dest      destination directory
         * @param  string $overwrite overwrite destination if exists
         * @return bool
         */
        public function move($src, $dest, $overwrite = false)
        {
            // @TODO algorithm is sloppy
            if (!IS_CLI) {
                $res = $this->query('file_move', $src, $dest, (bool)$overwrite);
                if ($res) {
                    $this->_purgeCache($src);
                }

                return $res;
            }

            if (!$src || !$dest) {
                return error('missing source/destination');
            }
            if ($this->permission_level & PRIVILEGE_SITE) {
                $optimized = (bool)$this->_optimizedShadowAssertion;
            } else {
                $optimized = false;
            }
            $dest_path = $this->make_path($dest, $link);
            $dest_parent = dirname($dest_path);
            if (!$link) {
                // $dest can be /dest/dir/name or /dest/dir where $src is appended
                $tmp = $this->make_path(\dirname($dest), $link);
                if ($link) {
                    $dest = $this->unmake_path($tmp) . basename($dest);
                }
            }
            if ($link) {
                $optimized = false;
            }
            if ($optimized && !$link) {
                // OverlayFS doesn't like direct operations on r/w branch
                // perform move onto synthetic fs
                $dest_parent = $this->make_shadow_path(dirname($dest));
            }
            $unmakeFn = $optimized ? 'unmake_shadow_path' : 'unmake_path';

            if (!file_exists($dest_parent)) {
                return error('move: destination directory `' . dirname($dest) . "' does not exist");
            } else if (!is_dir($dest_parent)) {
                return error('move: `' . dirname($dest) . "' is not a directory");
            } else if (!$optimized && !$this->can_descend($dest_parent)) {
                return error('move: `' . dirname($dest) . "' cannot access - permission denied");
            }

            if (!is_array($src)) {
                if ($src[-1] === '/') {
                    $stat = $this->file_stat($src);
                    if ($stat && $stat['can_read'] && $stat['can_execute']) {
                        $srcset = [];
                        $files = scandir($this->domain_fs_path($src), SCANDIR_SORT_NONE);
                        foreach ($files as $file) {
                            if ($file === '..' || $file === '.') {
                                continue;
                            }
                            $srcset[] = "${src}/${file}";
                        }
                        return $this->move($srcset, $dest, $overwrite);
                    }
                }

                $src = array($src);
            }

            // straight file rename
            // mv /a.txt /b.txt
            if (!file_exists($dest_path)) {
                if (isset($src[1])) {
                    return error('move: cannot rename multiple files to new file');
                }
                $parent = $this->{$unmakeFn}($dest_parent);
            } else {
                $parent = $this->unmake_path($dest_path);
            }
            if ($optimized) {
                $dest_pstat = [
                    'file_type' => filetype($dest_parent)
                ];
            } else {
                $dest_pstat = $this->stat($parent);
            }

            // verify that dest is writeable
            if (!$optimized && (!$dest_pstat['can_write'] || !$dest_pstat['can_execute'])) {
                return error('move: `' . $parent . "' cannot write - permission denied");
            }

            // move all files
            $nchanged = -1;
            $perm_cache = array();
            // single item
            $isRename = !isset($src[1]);
            $destIsDir = $dest_pstat['file_type'] == 'dir';

            for ($i = 0, $nsrc = sizeof($src); $i < $nsrc; $i++) {
                $lchanged = $nchanged;
                $nchanged = 0;
                $link = '';
                $file = $src[$i];
                $src_path = $this->make_path($file, $link);

                if (!file_exists($src_path)) {
                    warn('move: `' . $file . "': No such file or directory");
                    continue;
                } else if ($src_path === $dest_path) {
                    warn('move: `' . $file . "': source and dest are the same");
                    continue;
                }

                if ($optimized) {
                    $src_stat = array(
                        'file_type' => filetype($src_path),
                        'uid'       => fileowner($src_path),
                        'link'      => \Util_PHP::is_link($src_path)
                    );
                } else {
                    $src_stat = $this->stat($file);
                }


                if (!$src_stat || $src_stat instanceof Exception) {
                    if ($src_stat instanceof Exception) {
                        warn('`' . $file . "': " . $src_stat->getMessage());
                    }
                    continue;
                }

                // source directory must have -wx permission
                $src_parent = dirname($src_path);
                if (!isset($perm_cache[$src_parent])) {
                    $src_pstat = $this->stat($this->unmake_path($src_parent));
                    $perm_cache[$src_parent] = !$src_pstat instanceof Exception && $src_pstat &&
                        $src_pstat['can_write'] && $src_pstat['can_execute'];
                }

                if (!$perm_cache[$src_parent]) {
                    warn('cannot move `' . $file . "' - permission denied");
                    continue;
                }

                $rename_dest = $dest_path;
                // add file/dirname onto destination if dest is exists/is dir
                if (!$isRename && $destIsDir) {
                    // destination is directory
                    $rename_dest = $dest_path . DIRECTORY_SEPARATOR . basename($file);
                } else if ($src_stat['file_type'] != 'dir' && file_exists($dest_path)) {
                    $rename_dest = $dest_path;
                } else if ($src_stat['file_type'] == 'dir' && is_dir($dest_path)) {
                    $rename_dest .= DIRECTORY_SEPARATOR . basename($file);
                }


                if (!$destIsDir && $src_stat['file_type'] == 'dir' &&
                    $dest_pstat['file_type'] == 'file'
                ) {
                    warn('cannot move `' . $file . "' - $dest is a file");
                    continue;
                }

                // need to do an extra round of lookups to
                // ensure files/directories are not clobbered
                if (file_exists($rename_dest)) {
                    if (!$overwrite) {
                        warn('cannot move `' . basename($file) . "' - destination `" . basename($rename_dest) . "' exists");
                        continue;
                    }
                    $del = $optimized ? unlink($rename_dest) : $this->delete($this->unmake_path($rename_dest), true);
                    if (!$del || $del instanceof Exception) {
                        if ($del instanceof Exception) {
                            warn("cannot remove file `$file' - " . $del->getMessage());
                        }
                        continue;

                    }
                }

                if ($src_stat['link']) {
                    // rename won't rename a symbolic link; delete and recreate the link
                    $this->delete(array($this->unmake_path($link)), false);
                    $this->symlink($src_stat['referent'], $parent);
                    if ($src_stat['uid'] >= User_Module::MIN_UID || $src_stat['uid'] === APACHE_UID) {
                        // don't worry about changing
                        // symlink if owner is root or apache
                        $nchanged = $lchanged & $this->chown_symlink($parent, $src_stat['owner']);
                    }

                    continue;
                }

                // CP runs Dav, Dav uses UPLOAD_UID
                if ($src_stat['uid'] == self::UPLOAD_UID) {
                    chown($src_path, $this->user_id);
                    chgrp($src_path, $this->group_id);
                }
                $rename_dest = rtrim($rename_dest, DIRECTORY_SEPARATOR);
                $nchanged = rename($src_path, $rename_dest) & $lchanged;
            }

            return $nchanged > 0;
        }

        /**
         * Create a symbolic link
         *
         * @param string $mSrc  source file
         * @param string $mDest destination link
         * @return bool
         */
        public function symlink(string $mSrc, string $mDest): bool
        {
            if (!IS_CLI) {
                return $this->query('file_symlink', $mSrc, $mDest) && $this->_purgeCache($mDest);
            }


            $target = '';
            if (0 === strncmp($mSrc, '..', 2)) {
                $mSrc = dirname($mDest) . '/' . $mSrc;
            }
            if ($mDest[strlen($mDest) - 1] == '/') {
                $mDest .= basename($mSrc);
            }

            $src_path = $this->make_path($mSrc, $haslink);
            if ($haslink) {
                $src_path = $haslink;
            }
            $link = $this->make_path($mDest, $target);
            clearstatcache(true, $link);
            clearstatcache(true, $src_path);
            if (file_exists($link)) {
                return error('destination `' . $this->unmake_path($link) . "' exists");
            } else if (!file_exists($src_path)) {
                return error('source `' . $this->unmake_path($src_path) . "' does not exist");
            } else if (!is_dir(\dirname($link))) {
                return error('Parent directory %s does not exist, cannot create symlink', \dirname($mDest));
            }

            // properly calculate relative traversals
            $target = self::convert_absolute_relative(realpath(dirname($link)) . '/' . basename($link), $src_path);

            //debug(self::convert_absolute_relative($dest_path, $src_path)." -> ".$dest_path);
            return symlink($target, $link) && $this->_purgeCache($mDest) && Util_PHP::lchown($link, $this->user_id)
                && Util_PHP::lchgrp($link, $this->group_id);
        }

        /**
         * Transform absolute path into relative path
         *
         * @param string $cwd  current working directory
         * @param string $path target symlink
         * @return string
         */
        public static function convert_absolute_relative(string $cwd, string $path): string
        {
            if (dirname($cwd) === rtrim($path, '/')) {
                return '../' . basename($path);
            } else if ($cwd === $path) {
                return '.';
            }

            $cwd = array_values(array_filter(explode('/', $cwd)));
            $path = array_values(array_filter(explode('/', $path)));
            // just in case PHP changes scoping rules in the future...
            $idx = 0;
            for ($idxMax = sizeof($cwd); $idx < $idxMax; $idx++) {
                if (!isset($path[$idx]) || ($path[$idx] !== $cwd[$idx])) {
                    break;
                }
            }

            return str_repeat('../', max(0, sizeof($cwd) - ($idx + 1))) . implode('/', array_slice($path, $idx));
        }

        /**
         * Change ownership of symbolic link
         *
         * @param string $mFile symbolic link
         * @param string $mUser target username
         * @return bool
         */
        public function chown_symlink($mFile, $mUser)
        {
            if (!IS_CLI) {
                $ret = $this->query('file_chown_symlink', $mFile, $mUser);
                $this->_purgeCache($mFile);

                return $ret;
            }
            $validUsers = array_keys($this->user_get_users());
            $validUsers[] = \Web_Module::WEB_USERNAME;
            if (!in_array($mUser, $validUsers, true)) {
                return error("invalid chown user `%s'", $mUser);
            }
            if (!is_array($mFile)) {
                $mFile = array($mFile);
            }

            $errors = array();
            $uid_cache = $this->user_get_users();

            // RHEL constant UID
            $uid_cache[Web_Module::WEB_USERNAME] = array('uid' => APACHE_UID);

            if (!isset($uid_cache[$mUser]['uid'])) {
                return new ArgumentError('Eep, unable to find UID for ' . $mUser);
            }

            $uid_cache = $uid_cache[$mUser]['uid'];
            foreach ($mFile as $file) {
                $link = '';
                $path = $this->make_path($file, $link);
                $stat = $this->stat($this->unmake_path(dirname($link)));
                if ($path instanceof Exception) {
                    $errors[$file] = $path->getMessage();
                } else {
                    if (($ex = $this->can_descend(dirname($path))) instanceof Exception || !$ex) {
                        $errors[$file] = $ex->getMessage();
                    } else if ($stat['can_chown']) {
                        if (!\Util_PHP::lchown($link, $uid_cache)) {
                            $errors[$file] = Error_Reporter::get_last_php_msg();
                        }

                    } else {
                        $errors[$file] = 'Unable to change user ownership of ' . $file;
                    }
                }
            }
            $this->_purgeCache($mFile);

            if (count($errors)) {
                throw new FileError(implode("\n", $errors));
            }

            return true;
        }

        public function file_exists($file, array &$missing = null)
        {
            deprecated_func('use exists');

            return $this->exists($file, $missing);
        }

        /**
         * Check existence of file
         *
         * @param string|array $file
         * @param array        $missing files not found
         * @return bool|array
         */
        public function exists($file, array &$missing = null)
        {
            if (!IS_CLI && (is_array($file) || !file_exists($this->make_path($file)))) {
                return $this->query('file_exists', $file);
            }

            /** CLI */
            if (!is_array($file)) {
                $file = array($file);
            }
            $exists = true;
            $do_missing = is_array($missing);
            for ($i = 0, $n = sizeof($file); $i < $n; $i++) {
                if (!$exists && $do_missing) {
                    $missing[] = $file[$i];
                }
                $path = $this->make_path($file[$i]);
                clearstatcache(true, $path);
                $exists = file_exists($path);
            }

            return $exists;
        }

        /**
         * Canonicalize jailed filesystem path
         *
         * @param  string $path
         * @return string
         */
        public function canonicalize_site($path)
        {
            if ($this->permission_level & PRIVILEGE_ADMIN) {
                return $path;
            }
            $prefix = $this->domain_fs_path();
            $len = strlen($prefix);
            if (0 === strpos($path, $prefix)) {
                $path = substr($path, $len);
            }

            return !$path ? '/' : $path;
        }

        /**
         * Canonicalize global path
         *
         * @param  string $path
         * @return string
         */
        public function canonicalize_abs($path)
        {

            if ($this->permission_level & PRIVILEGE_ADMIN) {
                return $path;
            }
            $prefix = $this->domain_fs_path();
            $len = strlen($prefix);
            if (0 !== strpos($path, $prefix)) {
                $path = $prefix . $path;
            }

            return $path;
        }

        /**
         * Assume ownership of uploaded files
         *
         * @param  mixed $files
         * @return bool
         */
        public function endow_upload($files)
        {
            if (!IS_CLI) {
                return $this->query('file_endow_upload', $files);
            }
            if (Error_Reporter::is_error()) {
                return error('cannot handle upload in inconsistent state');
            }

            if (!is_array($files)) {
                $files = array($files);
            }

            for ($i = 0, $n = sizeof($files); $i < $n; $i++) {
                $file = $files[$i];
                if ($file[0] === '.' || $file[0] === '/') {
                    warn("invalid file to endow upload `%s', skipping (must reside in `%s'", $file, TEMP_DIR);
                }
                $path = $this->make_path(TEMP_DIR . '/' . $file);
                $base = $this->make_path(TEMP_DIR);
                if (0 !== strpos($path, $base . '/')) {
                    error("file `$file' contains invalid characters");
                    report("Invalid chars? $path $base $file");
                    continue;
                } else {
                    if (!file_exists($path)) {
                        error('file `' . TEMP_DIR . "/$file' does not exist");
                        continue;
                    } else {
                        $stat = $this->stat(TEMP_DIR . '/' . $file);
                        if ($stat['uid'] != self::UPLOAD_UID || $stat['file_type'] != 'file'
                            || $stat['nlinks'] > 1 || $stat['link'] != 0
                        ) {
                            error("file `$file' is not an uploaded file");
                            continue;
                        }
                    }
                }
                file_exists($path) && chown($path, $this->user_id) && chgrp($path, $this->group_id);
            }

            return !Error_Reporter::is_error();
        }

        /**
         * Create or alter the timestamp of a file
         *
         * @param string $file filename
         * @param int    $time optional unix timestamp
         * @return bool
         */
        public function touch($file, $time = null)
        {
            if (!IS_CLI) {
                return $this->query('file_touch', $file, $time);
            }
            if (!$file) {
                return error('no filename specified');
            }
            if (is_null($time)) {
                $time = time();
            } else if ((int)$time != $time || $time < 0) {
                return error("invalid time spec `%d'", $time);
            }

            $path = $this->make_path($file);
            if (!$path) {
                return error('invalid file path `%s', $file);
            }
            $exists = file_exists($path);
            if ($exists) {
                $stat = $this->stat($file);
                if (!$stat) {
                    return error("stat failed on `%s'", $file);
                } else if (!$stat['can_write']) {
                    return error("cannot modify file `%s'", $file);
                }
            } else {
                $stat = $this->stat_backend(\dirname($file));
                if (!$stat['can_write']) {
                    return error("Cannot write to file `%s': permission denied", $file);
                }
            }
            $ret = touch($path, $time);
            if (!$exists) {
                chown($path, (int)$this->user_id);
                chgrp($path, (int)$this->group_id);
            }

            return $ret;
        }

        /**
         * Create a pipe destination to compress
         *
         * @param array $files
         * @return string $path
         */
        public function initialize_download(array $files)
        {
            if (!IS_CLI) {
                return $this->query('file_initialize_download', $files);
            }

            // @XXX potential race condition
            $fifo = tempnam('/tmp', 'id-' . $this->site);
            unlink($fifo);
            if (!posix_mkfifo($fifo, 0600)) {
                return error('failed to ready pipe for archive');
            }

            $newfiles = array();
            // do a separate path for unprivileged users
            $isUser = $this->permission_level & PRIVILEGE_USER == PRIVILEGE_USER;
            foreach ($files as $f) {
                if (false !== strpos($f, '..') || $f[0] !== '/') {
                    // naughty naughty!
                    continue;
                } else if (!isset($f[1])) {
                    $f = '/.';
                }
                if ($isUser) {
                    $stat = $this->stat($f);
                    if ($stat['uid'] != $this->user_id) {
                        warn("file `%s' not owned by %s, skipping", $f, $this->username);
                        continue;
                    }
                }
                $newfiles[] = substr($f, 1);
            }
            if (!$newfiles) {
                return error('nothing to download!');
            }
            $filelist = tempnam('/tmp', 'fl');
            chmod($filelist, 0600);
            chown($fifo, self::UPLOAD_UID);
            file_put_contents($filelist, join("\n", $newfiles));

            $proc = new Util_Process_Fork();

            // lowest priority
            $proc->setPriority(19);
            // need absolute path to pcntl_exec
            $xtrainclude = null;
            $ret = $proc->run('/bin/tar --directory %(shadow)s -cf %(fifo)s %(xtrainclude)s --exclude-from=%(skipfile)s ' .
                '--one-file-system --files-from=%(list)s ',
                array(
                    'xtrainclude' => $xtrainclude,
                    'shadow'      => $this->domain_shadow_path(),
                    'fifo'        => $fifo,
                    'list'        => $filelist,
                    'skipfile'    => INCLUDE_PATH . self::DOWNLOAD_SKIP_LIST
                )
            );

            return $ret['success'] ? $fifo : false;
        }

        /**
         * Set file access control lists
         *
         * - user if omitted removes all ACL entries
         * - permissions may be of the form:
         *  octal (0, 1, 2, 4 and any combo thereof)
         *  or
         *  drwx, d sets default
         *  setting permission null will remove all permissions
         * - "0" permission will disallow all access for named user
         *
         * @param string|array      $file
         * @param string|null|array $user
         * @param int|string|null   $permission
         * @param array             $xtra map of default: bool, recursive: bool (not manageable by subuser)
         * @return bool
         */
        public function set_acls($file, $user = null, $permission = null, array $xtra = array())
        {
            if (!IS_CLI) {
                return $this->query('file_set_acls', $file, $user, $permission, $xtra);
            }
            if (null !== $permission && ctype_digit($permission)) {
                $permission = intval($permission);
            }
            // @todo bring API up to consistent definition
            if (!empty($xtra['recursive'])) {
                $xtra[self::ACL_MODE_RECURSIVE] = 1;
            }
            if (!empty($xtra['default'])) {
                $xtra[self::ACL_MODE_DEFAULT] = 1;
            }
            if (!version_compare(platform_version(), '4.5', '>=')) {
                return error("`%s': only available on platform 4.5+", __FUNCTION__);
            }
            $uuidmap = $this->permittedUsers();
            $file = (array)$file;
            $sfiles = array();
            $prefix = $this->make_shadow_path('');
            $prefixlen = strlen($prefix);
            foreach ($file as $tmp) {
                $shadow = $this->make_shadow_path($tmp);
                $glob = glob($shadow, GLOB_NOSORT);
                foreach ($glob as $shadow) {
                    if (0 !== strpos($shadow, $prefix)) {
                        // malicious bastard
                        continue;
                    }
                    if (!$shadow) {
                        error("skipping invalid path `%s'", $tmp);
                        continue;
                    } else if (!file_exists($shadow)) {
                        error("skipping missing path `%s'", $tmp);
                        continue;
                    }

                    $f = substr($shadow, $prefixlen);
                    if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_ADMIN)) {
                        // do an extra expensive stat check to ensure ownership
                        // prevent symlink attacks on /etc/passwd
                        $stat = $this->stat($f);
                        if (!$stat['can_chown']) {
                            error('%s: cannot change ownership attributes', $f);
                            continue;
                        }

                    }
                    $sfiles[] = $shadow;
                }
            }

            if (!$sfiles) {
                return error('no files to adjust!');
            }

            // never follow symlinks
            $flags = '-P';
            if (!$user) {
                $flags .= 'b';
                if (is_array($permission)) {
                    $xtra = $permission;
                    $permission = array();
                }

            } else if (!is_array($user)) {
                $user = array($user => $permission);
            } else if (is_array($user) && is_array($permission)) {
                // arguments passed in map format
                $xtra = $permission;
                // arguments passed with implicit true assumption
                $permission = null;
            } else if (is_array($user)) {
                // todo
            }
            if (array_key_exists(0, $xtra)) {
                $xtra = array_fill_keys($xtra, true);
            }
            $xtra = array_merge(
                array(
                    self::ACL_MODE_DEFAULT   => false,
                    self::ACL_MODE_RECURSIVE => false,
                    self::ACL_NO_RECALC_MASK => false,
                ), $xtra
            );
            if ($xtra[self::ACL_MODE_DEFAULT]) {
                $flags .= 'd';
            }
            if ($xtra[self::ACL_NO_RECALC_MASK]) {
                $flags .= 'n';
            }
            if (!($this->permission_level & PRIVILEGE_USER) && $xtra[self::ACL_MODE_RECURSIVE]) {
                $flags .= 'R';
            }
            if (0 < ($pos = strspn($flags, self::ACL_FLAGS)) && isset($flags[$pos])) {
                return error('unrecognized acl flag: %s', $flags[$pos]);
            }
            $map = array();

            // just removing acls from all files?
            if (!$user) {
                return $this->_acl_driver($sfiles, $flags);
            }

            // build a permission map
            foreach ($user as $u => $perms) {
                // passed as [[apache: 7], [foo: 7], [apache: drwx]] ...
                if (is_array($perms)) {
                    $u = key($perms);
                    $perms = current($perms);
                }

                if (!isset($uuidmap[$u])) {
                    return error("invalid user `%s',", $u);
                }

                $default = false;
                $flag = 'm';
                if (is_null($perms)) {
                    $flag = 'x';
                } else if (!ctype_digit((string)$perms)) {
                    if (0 < ($pos = strspn($perms, 'drwx')) && isset($perms[$pos])) {
                        // permissions provided as chars, verify it's sensible
                        return error("unknown permission mode `%s' setting for user `%s'",
                            $perms[$pos], $u
                        );
                    }
                    $tmp = 0;
                    for ($i = 0, $n = strlen($perms); $i < $n; $i++) {
                        if ($perms[$i] === 'r') {
                            $tmp |= 4;
                        } else if ($perms[$i] === 'w') {
                            $tmp |= 2;
                        } else if ($perms[$i] === 'x') {
                            $tmp |= 1;
                        } else if ($perms[$i] === 'd' && !$xtra[self::ACL_MODE_DEFAULT]) {
                            $default = true;
                        }
                    }
                    $perms = $tmp;
                }

                $uid = $uuidmap[$u];
                $map[] = sprintf('-%s %su:%u%s',
                    $flag,
                    ($default ? 'd:' : ''),
                    $uid,
                    (is_null($perms) ? null : ':' . $perms)
                );
            }

            if (!$this->_acl_driver($sfiles, $flags, $map)) {
                return false;
            }
            /**
             * Flush cached ACL entries
             *
             * @todo unify acl cache management
             */
            $cache = \Cache_Account::spawn($this->getAuthContext());
            foreach (array_unique(array_map('\dirname', $file)) as $dir) {
                $key = $this->site_id . '|' . $dir;
                unset($this->acl_cache[$key]);
                $cache->delete('acl:' . $key);
            }

            return true;
        }

        private function _acl_driver(array $files, $flags, array $rights = array())
        {
            $shadow = $this->domain_shadow_path();
            if ($flags[0] !== '-') {
                return error('acl flags garbled');
            }
            if (0 !== strpos($files[0], $shadow)) {
                return error('crit: acl path error?!!');
            }

            $cmd = 'setfacl ' . $flags . ' ' . join(' ', $rights);
            $cmd .= str_repeat(' %s', count($files));
            $proc = Util_Process_Safe::exec($cmd, $files);

            if (!$proc['success']) {
                return error("setting ACLs failed: `%s'", $proc['stderr']);
            }

            return true;
        }

        /**
         * Build up shadow layer component wrapper
         * {@see shadow_buildup_backend}
         *
         * @param string $path file or directory to verify
         * @return bool
         */
        public function shadow_buildup($path)
        {
            // validate permissions permit
            $parent = dirname($path);
            $tok = strtok($parent, '/');
            $chkpath = '';
            do {
                $chkpath .= '/' . $tok;
                if (!$this->exists($chkpath)) {
                    break;
                }
            } while (false !== ($tok = strtok('/')));
            $chkpath = \dirname($chkpath);
            $stat = $this->file_stat($chkpath);
            if (!$stat['can_write'] || !$stat['can_descend']) {
                return error('Cannot build up path %s: permission denied by %s', $path, $chkpath);
            }

            return $this->query('file_shadow_buildup_backend', $path, $this->user_id);

        }

        /**
         * Build up shadow layer components up to the final piece
         *
         * @param string     $path file or directory to verify
         * @param string|int $user user to set on buildup (default: current user on non CLI)
         * @param int        $perm permission in octal form
         * @return bool
         */
        public function shadow_buildup_backend($path, $user = 'root', $perm = 0755)
        {
            if (version_compare(platform_version(), '6', '<')) {
                // bypass on Helios (aufs)
                return true;
            }
            $shadowprefix = $this->domain_shadow_path();
            $prefix = $this->domain_fs_path();
            /**
             * Flexible parsing on path input
             */
            if (0 === strpos($path, $prefix)) {
                $path = substr($path, strlen($prefix));
            }
            if (0 !== strpos($path, $shadowprefix)) {
                $path = $this->make_shadow_path($path);
            }
            $parent = dirname($path);
            $tok = strtok($parent, '/');
            $chkpath = '';
            do {
                $chkpath .= '/' . $tok;
                if (!file_exists($chkpath)) {
                    break;
                }

            } while (false !== ($tok = strtok('/')));

            if (false === $tok) {
                return true;
            }

            if (0 === strpos($chkpath, $shadowprefix)) {
                $chkpath = $this->domain_shadow_path() .
                    substr($chkpath, strlen($shadowprefix));
            }
            do {
                \Opcenter\Filesystem::mkdir($chkpath, $user, $this->group_id, $perm);
                $remaining = strtok('/');
                $chkpath .= '/' . $remaining;
            } while (false !== $remaining);

            // and drop OverlayFS cache
            return $this->purge();
        }

        /**
         * Reset ownership of files in a path
         *
         * @see takeover_user() to change ownership without altering permissions
         *
         * @param string      $path
         * @param null|string $user     set to null to bypass user reset, blank string current user
         * @param string|int  $fileperm file permission to reset
         * @param int         $dirperm  directory permission to reset
         * @return bool
         */
        public function reset_path(string $path, ?string $user = '', $fileperm = 644, $dirperm = 755): bool
        {
            if (!IS_CLI) {
                return $this->query('file_reset_path', $path, $user, $fileperm, $dirperm);
            }

            $usercmd = null;
            $acceptableUids = [
                $this->user_get_uid_from_username(\Web_Module::WEB_USERNAME),
            ];
            if ($user === '') {
                $user = $this->username;
            }
            if ($user) {
                $uid = (int)$user;
                if ($uid !== $user) {
                    $uid = $this->user_get_uid_from_username($user);
                }
                if ($this->tomcat_permitted()) {
                    $acceptableUids[] = $this->user_get_uid_from_username($this->tomcat_system_user());
                }

                if ($uid < \User_Module::MIN_UID && !in_array($uid, $acceptableUids, true)) {
                    return error("user `%s' is unknown or a system user", $user);
                }
                $usercmd = '-exec chown -h ' . (int)$uid . ' "{}" \+';
            }

            $shadowpath = $this->make_shadow_path($path);
            if (!file_exists($shadowpath)) {
                return error("path `%s' does not exist", $path);
            }

            if (is_int($fileperm)) {
                $fileperm = (string)$fileperm;
            }

            if (is_int($dirperm)) {
                $dirperm = (string)$dirperm;
            }

            $stat = $this->stat_backend($path);
            if (!$stat['can_write']) {
                return error("cannot reset path `%s' without write permissions", $path);
            } else if ($stat['uid'] < \User_Module::MIN_UID && !in_array($stat['uid'], $acceptableUids)) {
                return error("unable to takeover, base path `%s' must be within acceptable UID range", $path);
            } else if ($fileperm[0] !== '0' && strlen((string)$fileperm) > 3) {
                return error('special perms may not be set for files');
            } else if ($dirperm[0] !== '0' && strlen((string)$dirperm) > 3) {
                return error('special perms may not be set for directories');
            } else if (strlen((string)$fileperm) !== strspn((string)$fileperm, '01234567')) {
                return error('file permission must be octal');
            } else if (strlen((string)$dirperm) !== strspn((string)$dirperm, '01234567')) {
                return error('directory permission must be octal');
            }

            $args = [
                'path'  => $shadowpath,
                'gid'   => $this->group_id,
                'fperm' => $fileperm,
                'dperm' => $dirperm,
            ];
            $ret = \Util_Process_Safe::exec(
                'find -P %(path)s -xdev -gid %(gid)d ' . $usercmd . ' \( -type f -exec chmod %(fperm)s "{}" \+ \) ' .
                '-o \( -type d -exec chmod %(dperm)s "{}" \+ \) -printf "%%P\n"',
                $args
            );
            if (!$ret['success']) {
                return error('failed to reset path, err: %s', $ret['stderr']);
            }
            $files = explode("\n", rtrim($ret['stdout']));
            if (!$files) {
                warn('no files changed');
            }
            $this->purge();

            return $ret['success'];
        }

        /**
         * Recursively convert ownership of files from one user to another
         *
         * @param string|int $olduser owner to convert
         * @param string|int $newuser new owner
         * @param string     $path    base path
         * @return bool|array
         */
        public function takeover_user($olduser, $newuser, string $path = '/')
        {
            if (!IS_CLI) {
                return $this->query('file_takeover_user', $olduser, $newuser, $path);
            }
            $newuid = (int)$newuser;
            $olduid = (int)$olduser;
            if ($olduid !== $olduser) {
                $olduid = $this->user_get_uid_from_username($olduser);
            }
            if ($newuid !== $newuser) {
                $newuid = $this->user_get_uid_from_username($newuser);
            }
            $acceptableUids = $this->permittedUsers();

            if ($olduid < \User_Module::MIN_UID && !in_array($olduid, $acceptableUids)) {
                return error("user `%s' is unknown or a system user", $olduser);
            }

            if ($newuid < \User_Module::MIN_UID && !in_array($newuid, $acceptableUids)) {
                return error("user `%s' is unknown or a system user", $newuser);
            }
            $shadowpath = $this->make_shadow_path($path);
            $stat = $this->stat_backend($path);
            if (!file_exists($shadowpath)) {
                return error("path `%s' does not exist", $path);
            } else if ($path !== '/' && $stat['uid'] < \User_Module::MIN_UID && !in_array($stat['uid'], $acceptableUids)) {
                return error("unable to takeover, base path `%s' must be within acceptable UID range", $path);
            }
            $args = [
                'path'   => $shadowpath,
                'gid'    => $this->group_id,
                'olduid' => $olduid,
                'newuid' => $newuid
            ];
            $ret = \Util_Process_Safe::exec(
                'find -P %(path)s -xdev -gid %(gid)d -uid %(olduid)d -exec chown -h %(newuid)d "{}" \; -printf "%%P\n"',
                $args
            );
            if (!$ret['success']) {
                return error('failed to convert ownership, err: %s', $ret['stderr']);
            }
            $files = explode("\n", rtrim($ret['stdout']));
            if (!$files) {
                warn('no files changed');
            }

            $this->purge(true);

            return $files;
        }

        /**
         * Scan a given path for malicious files
         *
         * @param string $path
         * @return string|null scan results or null on error
         */
        public function scan(string $path): ?string
        {
            if (!ANTIVIRUS_INSTALLED) {
                error('No AV installed');
                return null;
            }
            $fstpath = $this->make_shadow_path($path);
            $prefix = $this->domain_shadow_path();
            if (!\count(glob($fstpath . '/*'))) {
                return null;
            }
            $ret = \Util_Process_Safe::exec(
                'clamdscan -mi %(path)s/*',
                ['path' => $fstpath],
                [0],
                ['reporterror' => false]
            );
            if (!$ret['success']) {
                // filter out needless warnings
                $ret['stderr'] = preg_replace('/^WARNING: .*$[\r\n]?/m', '', $ret['stderr']);
                if ($ret['stderr']) {
                    error('Failed to scan %s: %s',
                        $path,
                        $ret['stderr']
                    );
                    return null;
                }
                warn('Potential malware discovered');
            }
            $output = [];
            $tok = strtok($ret['output'], "\n");
            $prefixlen = strlen($prefix);
            while (false !== $tok) {
                $output[] = 0 === strpos($tok, $prefix) ? substr($tok, $prefixlen) : $tok;
                $tok = strtok("\n");
            }
            return implode("\n", $output);
        }

        public function _delete()
        {
            // make sure we dump overlayfs' page cache
            if (version_compare(platform_version(), '6.5', '>=')) {
                $this->purge();
            }
        }
    }