diff --git a/afs.php b/afs.php new file mode 100644 index 00000000..5b5b0eab --- /dev/null +++ b/afs.php @@ -0,0 +1,2750 @@ +Couldn't load necessary posix function

\n"; + exit( 1 ); +} + +/* +if ( !extension_loaded( 'filedrawers' )) { + error_log( "Couldn't load Filedrawers PECL extension" ); + echo "

Couldn't load necessary Filedrawers extension

\n"; + exit( 1 ); +} +*/ + +// remember this class needs to have $this->path set +class Afs +{ + protected $selectedItems; + protected $afsUtils = '/usr/bin'; + protected $afsRoot = '/afs'; + public $confirmMsg = ''; + public $errorMsg = ''; + public $notifyMsg = ''; + public $parPath; // Path to the parent of current path + public $filename = ''; + public $adminPriv = 0; + public $deletePriv = 0; + public $insertPriv = 0; + public $lockPriv = 0; + public $lookupPriv = 0; + public $readPriv = 0; + public $writePriv = 0; + public $path = ''; + public $sid = ''; + public $type = ''; + public $mimetype = ''; + public $formKey = ''; + private $uniqname = ''; + protected $afsStat; + protected $afsAvailable = false; + protected $credentialIdentity = ''; + protected $lastFsStatus = 0; + protected $newName = ''; + protected $originPath = ''; + protected $startCWD = ''; + + public function __construct( $path="" ) + { + $this->uniqname = isset( $_SERVER['REMOTE_USER'] ) + ? $_SERVER['REMOTE_USER'] : ''; + $this->credentialIdentity = $this->uniqname; + $this->startCWD = getcwd(); + $this->afsStat = @stat( $this->afsRoot ); + + // Bug 2634811 Fixed: Make sure /afs isn't on the local filesystem + $rootStat = @stat( '/' ); + + if ( !is_array( $this->afsStat ) || !is_array( $rootStat ) + || $this->afsStat['dev'] == $rootStat['dev'] ) { + error_log( "$this->afsRoot is unavailable or has the same device ID as / " . + "(is afs actually mounted?): $this->uniqname, " . + "$this->errorMsg " . __FILE__ ); + $this->errorMsg = 'AFS is not mounted.'; + return; + } + + $this->afsAvailable = true; + + // Bug 1975875 Fixed: Don't trim whitespaces from path + if ( !$this->setPath( $path )) { + return; + } + + // Generate the path of the folder one level above the current + if ( !preg_match( "/(.*\/)([^\/]+)\/?$/", $this->path, $Matches )) { + error_log( "missing homedir: [$this->path] $this->uniqname, " . + "$this->errorMsg " . __FILE__ ); + $this->errorMsg = 'Missing home directory.'; + return; + } + $this->parPath = $Matches[1]; + $this->filename = $Matches[2]; + + if ( !isset( $_SESSION['formKey'] )) { + $_SESSION['formKey'] = md5( uniqid( rand(), true )); + } + + $this->formKey = $_SESSION['formKey']; + $this->sid = md5( uniqid( rand(), true )); + } + + + // Symlink safe method to determine file type + public function getType() + { + if ( !$this->makePathAFSlocal( dirname( $this->path ))) { + return false; + } + + clearstatcache(); + if ( @filetype( basename( $this->path )) == 'dir' ) { + @chdir( $this->startCWD ); + return 'dir'; + } else { + clearstatcache(); + $type = @filetype( basename( $this->path )); + + if ( $type == 'file' ) { + $this->mimetype = function_exists( 'fm_get_mime_type' ) + ? fm_get_mime_type( basename( $this->path )) + : 'application/octet-stream'; + @chdir( $this->startCWD ); + return $type; + } else { + @chdir( $this->startCWD ); + return 'none'; + } + } + + @chdir( $this->startCWD ); + } + + + public function processCommand() + { + if ( !isset( $_POST['command'] ) || $this->formKey != $_POST['formKey'] ) { + return false; + } + + $this->setSelectedItems(); + + switch ( $_POST['command'] ) { + case 'newfolder': + $this->createFolder(); + break; + case 'rename': + $this->setNewItemName(); + $this->afsRename(); + break; + case 'cut': + $this->setOriginPath(); + $this->moveFiles(); + break; + case 'copy': + $this->setOriginPath(); + $this->copyFiles(); + break; + case 'delete': + $this->deleteFiles(); + break; + default: + break; + } + } + + + /* + * This function sets the "target" of an operation + * (what file(s) or folder(s) + * to perform the selected action on. + */ + protected function setSelectedItems() + { + if ( isset( $_POST['selectedItems'] ) + && is_array( $_POST['selectedItems'] )) { + $this->selectedItems = array(); + + foreach ( $_POST['selectedItems'] as $key=>$item ) { + $this->selectedItems[$key] = $item; + } + } else if ( isset( $_POST['selectedItems'] )) { + $this->selectedItems = $_POST['selectedItems']; + } + } + + + // Some functions like cut or paste need to know where a file is coming from + // in addition to where it is going + public function setOriginPath() + { + if ( isset( $_POST['originPath'] )) { + $this->originPath = $this->pathSecurity( $_POST['originPath'] ); + } + } + + + public function setNewItemName() + { + if ( isset( $_POST['newName'] )) { + $this->newName = $_POST['newName']; + } + } + + + public function createFolder() + { + if ( !$this->makePathAFSlocal( $this->path )) { + return false; + } + + if ( $this->selectedItems != 'Please enter a name for your new folder.' ) { + if ( $this->linkSafeFileExists( basename( $this->selectedItems ))) { + $this->errorMsg = "The folder \'$this->selectedItems\' " . + "already exists. Please select a different name."; + @chdir( $this->startCWD ); + return false; + } + + if ( !mkdir( trim( basename( $this->selectedItems )), 0755, true )) { + $this->errorMsg = 'Unable to create folder.'; + @chdir( $this->startCWD ); + return false; + } + + @chdir( $this->startCWD ); + return true; + } + } + + + // Remove an existing folder + // jackylee at eml dot cc + public function removeFolder( $folderPath ) + { + if ( !$this->makePathAFSlocal( $folderPath )) { + return false; + } + + if ( !$handle = @opendir( '.' )) { + $this->errorMsg = 'Unable to remove the folder because ' . + 'it no longer exists.'; + @chdir( $this->startCWD ); + return false; + } + + while ( false !== ( $item = readdir( $handle ))) { + + if ( $item == "." || $item == ".." ) { + continue; + } + + $itemPath = $folderPath . '/' . $item; + + if ( is_dir( $itemPath ) && !is_link( $itemPath )) { + if ( !$this->removeFolder( $itemPath )) { + @chdir( $this->startCWD ); + return false; + } + } else { + if ( !$this->makePathAFSlocal( $folderPath )) { + @chdir( $this->startCWD ); + return false; + } + + unlink( basename( $item )); + @chdir( $this->startCWD ); + } + } + + closedir( $handle ); + + if ( !$this->makePathAFSlocal( $folderPath )) { + @chdir( $this->startCWD ); + return false; + } + + if ( rmdir( '../' . basename( getcwd()))) { + $this->notifyMsg = "Successfully deleted file(s)."; + @chdir( $this->startCWD ); + return true; + } + + @chdir( $this->startCWD ); + $this->errorMsg = 'Unable to remove the folder.'; + return false; + } + + + // Delete specified files + public function deleteFiles() + { + if ( ! $this->selectedItems ) { + return false; + } + + // XXX 0.5.0 should use a data structure that doesn't require splitting + // on a whitespace character + $files = explode( "\n", $this->selectedItems ); + + foreach ( $files as $file ) { + $file = preg_replace( "/[\r\n]/", '', $file ); + + if ( empty( $file )) { + continue; + } + + // Security checks are in Afs::removeFolder() + $itemPath = $this->path . '/' . $file; + + if ( is_dir( $itemPath ) && !is_link( $itemPath )) { + if ( !$this->removeFolder( $itemPath )) { + return false; + } + } else { + if ( !$this->makePathAFSlocal( $this->path )) { + return false; + } + + if ( !@unlink( basename( $file ))) { + @chdir( $this->startCWD ); + $this->errorMsg = "Unable to delete $file."; + return false; + } else { + @chdir( $this->startCWD ); + $this->notifyMsg = "Successfully deleted file(s)."; + } + } + } + + @chdir( $this->startCWD ); + return true; + } + + + public function afsRename() + { + + if ( $this->selectedItems == $this->newName ) { + return false; + } + + if ( !$this->makePathAFSlocal( $this->path )) { + return false; + } + + if ( is_link( basename( $this->selectedItems ))) { + $this->errorMsg = "Symbolic links cannot be renamed."; + @chdir( $this->startCWD ); + return false; + } + + $newName = trim( basename( $this->newName )); + + if ( $this->linkSafeFileExists( $newName )) { + $this->errorMsg = "The file or folder '" . $newName . + "' already exists. Please select a different name."; + @chdir( $this->startCWD ); + return false; + } + + if ( !function_exists( 'filedrawers_rename' )) { + $this->errorMsg = 'AFS-safe rename support is unavailable.'; + @chdir( $this->startCWD ); + return false; + } + + if ( !@filedrawers_rename( basename( $this->selectedItems ), + $newName, $this->afsRoot )) { + $this->errorMsg = 'Unable to rename this file or folder.'; + @chdir( $this->startCWD ); + return false; + } + + @chdir( $this->startCWD ); + return true; + } + + /* + * Move files from one directory to another + * This will clobber an existing file with the same name + */ + function moveFiles() + { + $files = explode( CLIPSEPARATOR, $this->selectedItems ); + + foreach ( $files as $file ) { + if ( empty( $file )) { + continue; + } + + // Security checks are in filedrawers_rename + $sourcePath = $this->originPath . '/' . $file; + $destPath = $this->path . '/' . $file; + + if ( !function_exists( 'filedrawers_rename' )) { + $this->errorMsg = 'AFS-safe move support is unavailable.'; + return false; + } + + if ( !@filedrawers_rename( $sourcePath, $destPath, $this->afsRoot )) { + $this->errorMsg = "Unable to move: $file."; + return false; + } + + $this->notifyMsg = "Pasted the contents of the clipboard."; + } + + return true; + } + + // Copy file from one directory to another + function copyFiles() + { + $files = explode( CLIPSEPARATOR, $this->selectedItems ); + + foreach ( $files as $file ) { + if ( empty( $file )) { + continue; + } + + // Link-safe dispatch and security checks are in copyItem(). + $sourcePath = $this->originPath . '/'. $file; + $destPath = $this->path . '/' . $file; + + if ( !$this->copyItem( $sourcePath, $destPath )) { + $this->errorMsg = "Unable to copy $file."; + return false; + } + + $this->notifyMsg = "Pasted the contents of the clipboard."; + } + + return true; + } + + + // Dispatch links before directory checks so a directory symlink is copied + // as a link and is never traversed by copy_dirs(). + protected function copyItem( $source, $target ) + { + if ( is_link( $source )) { + return $this->copy( $source, $target ); + } + + $type = @filetype( $source ); + if ( $type === 'dir' ) { + return $this->copy_dirs( $source, $target ); + } + if ( $type === 'file' ) { + return $this->copy( $source, $target ); + } + + return false; + } + + + /* A helper function for copyFiles(). Copies an entire directory at once. + * Original author: swizec at swizec dot com, php.net + */ + public function copy_dirs( $source, $target ) + { + if ( is_link( $source ) || !is_dir( $source )) { + return false; + } + + $sourceReal = @realpath( $source ); + $targetParentReal = @realpath( dirname( $target )); + if ( $sourceReal === false || $targetParentReal === false ) { + return false; + } + + $sourcePrefix = rtrim( $sourceReal, '/' ) . '/'; + $targetParentPrefix = rtrim( $targetParentReal, '/' ) . '/'; + if ( $targetParentReal === $sourceReal + || strpos( $targetParentPrefix, $sourcePrefix ) === 0 ) { + return false; + } + + if ( !$this->makePathAFSlocal( dirname( $target ))) { + return false; + } + + $targetCheck = getcwd(); + + if ( !@mkdir( basename( $target ), 0755 )) { + @chdir( $this->startCWD ); + return false; + } + + if ( !$this->makePathAFSlocal( $source )) { + @chdir( $this->startCWD ); + return false; + } + + $destCheck = getcwd(); + + // Prevent copying directory inside of itself + if ( $targetCheck == $destCheck ) { + @chdir( $this->startCWD ); + return false; + } + + $dir = dir( '.' ); + + while ( false !== ( $entry = $dir->read())) { + if ( $entry == '.' || $entry == '..' ) { + continue; + } + + $sourcePath = $source . '/' . $entry; + $targetPath = $target . '/' . $entry; + + // Link-safe dispatch and security checks are in copyItem(). + if ( !$this->copyItem( $sourcePath, $targetPath )) { + @chdir( $this->startCWD ); + return false; + } + } + + $dir->close(); + @chdir( $this->startCWD ); + return true; + } + + + /* An AFS safe version of the PHP copy builtin - this will only copy + * a file with a source and destination in AFS. If we relied on the + * copy builtin, there is a small possibility of a race condition where + * the copy could be symlink'ed out of AFS. This function works on file + * handles only after making sure the source and destination are in AFS. + */ + public function copy( $source, $dest ) + { + if ( !$this->afsAvailable || !is_array( $this->afsStat )) { + return false; + } + + if ( is_link( $source )) { + if ( !$this->makePathAFSlocal( dirname( $source ))) { + return false; + } + + $name = basename( $source ); + $target = readlink( $name ); + + if ( !$this->makePathAFSlocal( dirname( $dest ))) { + @chdir( $this->startCWD ); + return false; + } + + if ( !symlink( $target, basename( $dest ))) { + @chdir( $this->startCWD ); + return false; + } + + @chdir( $this->startCWD ); + return true; + } + + if ( !( $sourceHdl = @fopen( $source, "rb" ))) { + @chdir( $this->startCWD ); + return false; + } + + $sourceStat = fstat( $sourceHdl ); + + if ( !is_array( $sourceStat ) + || $sourceStat['dev'] != $this->afsStat['dev'] ) { + @fclose( $sourceHdl ); + @chdir( $this->startCWD ); + return false; + } + + if ( !$this->makePathAFSlocal( dirname( $dest ))) { + @fclose( $sourceHdl ); + @chdir( $this->startCWD ); + return false; + } + + // If you want copy to overwrite, then do unlink(basename($dest)) here + if ( !( $destHdl = @fopen( basename( $dest ), "xb" ))) { + @fclose( $sourceHdl ); + @chdir( $this->startCWD ); + return false; + } + + $copied = true; + while ( !feof( $sourceHdl )) { + $buffer = fread( $sourceHdl, 1024 * 1024 ); + if ( $buffer === false ) { + $copied = false; + break; + } + + $written = 0; + $length = strlen( $buffer ); + while ( $written < $length ) { + $bytes = fwrite( $destHdl, substr( $buffer, $written )); + if ( $bytes === false || $bytes === 0 ) { + $copied = false; + break 2; + } + $written += $bytes; + } + } + + if ( !@fflush( $destHdl )) { + $copied = false; + } + @fclose( $sourceHdl ); + if ( !@fclose( $destHdl )) { + $copied = false; + } + + if ( !$copied ) { + @unlink( basename( $dest )); + } + @chdir( $this->startCWD ); + + return $copied; + } + + + // A AFS safe version of the PHP readfile builtin - this will only + // read files which are hosted in AFS. + function readfile() + { + if ( !$this->afsAvailable || !is_array( $this->afsStat )) { + return false; + } + + clearstatcache(); + + if ( $handle = @fopen( $this->path, "rb" )) { + $stat = fstat( $handle ); + if ( is_array( $stat ) && $stat['dev'] == $this->afsStat['dev'] ) { + while ( !feof( $handle )) { + $buffer = fread( $handle, 1024 * 1024 ); + if ( $buffer === false ) { + @fclose( $handle ); + return false; + } + echo $buffer; + } + @fclose( $handle ); + return true; + } + + @fclose( $handle ); + } + + return false; + } + + // Change the ACL for a given path + function changeAcl($entity, + $rights, + $path='', + $recursive=false, + $negative=false ) + { + $path = ( $path ) ? $path : $this->path; + $path = $this->pathSecurity( $path ); + $rights = trim( $rights ); + + if ( !$path || empty( $entity ) + || !preg_match( '/^(none|[lrwidkaA-H]{1,15})$/', $rights )) { + $this->errorMsg = + 'Warning: Invalid access control list request.'; + return false; + } + + if ( !$recursive ) { + return $this->changeAclEntries( + array( $entity => $rights ), $path, $negative ); + } + + $paths = array( $path ); + if ( $recursive && is_dir( $path )) { + $flags = FilesystemIterator::SKIP_DOTS; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, $flags ), + RecursiveIteratorIterator::SELF_FIRST ); + + foreach ( $iterator as $item ) { + if ( $item->isDir() && !$item->isLink()) { + $safePath = $this->pathSecurity( $item->getPathname()); + if ( !$safePath ) { + return false; + } + $paths[] = $safePath; + } + } + } + + foreach ( $paths as $aclPath ) { + $arguments = array( 'sa' ); + if ( $negative ) { + $arguments[] = '-negative'; + } + $arguments[] = $aclPath; + $arguments[] = $entity; + $arguments[] = $rights; + + $result = $this->runFs( $arguments ); + if ( $result === false || $this->lastFsStatus !== 0 + || preg_match( '/(^|\n)fs:/', $result )) { + $this->errorMsg = + 'Warning: Unable to modify the access control list.'; + return false; + } + } + + return true; + } + + // Change multiple ACL entries in one fs invocation to avoid per-ACE + // partial updates and subprocess overhead. + function changeAclEntries( $entries, $path='', $negative=false ) + { + $path = ( $path ) ? $path : $this->path; + $path = $this->pathSecurity( $path ); + if ( !$path || !is_array( $entries ) || empty( $entries )) { + return false; + } + + $arguments = array( 'sa' ); + if ( $negative ) { + $arguments[] = '-negative'; + } + $arguments[] = $path; + + foreach ( $entries as $entity => $rights ) { + $rights = trim( $rights ); + if ( $entity === '' + || !preg_match( '/^(none|[lrwidkaA-H]{1,15})$/', $rights )) { + $this->errorMsg = + 'Warning: Invalid access control list request.'; + return false; + } + $arguments[] = $entity; + $arguments[] = $rights; + } + + $result = $this->runFs( $arguments ); + if ( $result === false || $this->lastFsStatus !== 0 + || preg_match( '/(^|\n)fs:/', $result )) { + $this->errorMsg = + 'Warning: Unable to modify the access control list.'; + return false; + } + + return true; + } + + // Return an array of ACL rights for the current path + function readAcl( $path='' ) + { + $path = ( $path ) ? $path : $this->path; + $path = $this->pathSecurity( $path ); + if ( !$path ) { + return false; + } + + $result = $this->runFs( array( 'listacl', $path )); + if ( $result === false || $this->lastFsStatus !== 0 + || preg_match( '/(^|\n)fs:/', $result )) { + $this->errorMsg = + 'Warning: Unable to read the access control list.'; + return false; + } + + $acl = $this->parseAclOutput( $result ); + if ( $acl === false ) { + $this->errorMsg = + 'Warning: Unable to parse the access control list.'; + } + + return $acl; + } + + public function parseAclOutput( $result ) + { + if ( !is_string( $result )) { + return false; + } + + $rights = array( 'l', 'r', 'w', 'i', 'd', 'k', 'a', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H' ); + $acl = array( + 'normal' => array(), + 'negative' => array(), + 'inherited' => false + ); + $section = ''; + $sawHeader = false; + $sawNormal = false; + $sawNegative = false; + $lines = preg_split( '/\r?\n/', $result ); + + foreach ( $lines as $line ) { + $line = trim( $line ); + if ( $line === '' ) { + continue; + } + if ( preg_match( '/^Access list( \(inherited\))? for .+ is$/i', + $line, $header )) { + if ( $sawHeader ) { + return false; + } + $sawHeader = true; + $acl['inherited'] = !empty( $header[1] ); + $section = ''; + continue; + } + if ( preg_match( '/^Volume access list for .+ is$/i', $line )) { + // A Volume Maximum ACL is a separate, read-only policy block. + // Never merge it into the editable object ACL. + return false; + } + if ( preg_match( '/^Normal rights:$/i', $line )) { + if ( !$sawHeader || $sawNormal ) { + return false; + } + $section = 'normal'; + $sawNormal = true; + continue; + } + if ( preg_match( '/^Negative rights:$/i', $line )) { + if ( !$sawNormal || $sawNegative ) { + return false; + } + $section = 'negative'; + $sawNegative = true; + continue; + } + if ( !$section || !preg_match( '/^(\S+)\s+(\S+)$/', $line, $matches )) { + return false; + } + if ( $matches[2] !== 'none' + && !preg_match( '/^[lrwidkaA-H]{1,15}$/', $matches[2] )) { + return false; + } + + $seenRights = array(); + foreach ( str_split( $matches[2] ) as $setRight ) { + if ( isset( $seenRights[$setRight] )) { + return false; + } + $seenRights[$setRight] = true; + } + + foreach ( $rights as $right ) { + $acl[$section][$matches[1]][$right] = + strpos( $matches[2], $right ) !== false; + } + } + + if ( !$sawHeader || !$sawNormal ) { + return false; + } + + return $acl; + } + + function getACLAccess( $path ) + { + $this->lookupPriv = 0; + $this->readPriv = 0; + $this->writePriv = 0; + $this->insertPriv = 0; + $this->deletePriv = 0; + $this->lockPriv = 0; + $this->adminPriv = 0; + + $path = $this->pathSecurity( $path ); + if ( !$path ) { + return ''; + } + + $result = $this->runFs( array( 'getcalleraccess', $path )); + if ( $result === false || $this->lastFsStatus !== 0 ) { + return ''; + } + + $acls = ''; + if ( preg_match( '/^Callers access to .* is ([lrwidkaA-H]{1,15})$/m', + $result, $Matches )) { + $acls = $Matches[1]; + $this->lookupPriv = strpos( $acls, 'l' ) !== false ? 1 : 0; + $this->readPriv = strpos( $acls, 'r' ) !== false ? 1 : 0; + $this->writePriv = strpos( $acls, 'w' ) !== false ? 1 : 0; + $this->insertPriv = strpos( $acls, 'i' ) !== false ? 1 : 0; + $this->deletePriv = strpos( $acls, 'd' ) !== false ? 1 : 0; + $this->lockPriv = strpos( $acls, 'k' ) !== false ? 1 : 0; + $this->adminPriv = strpos( $acls, 'a' ) !== false ? 1 : 0; + } + return $acls; + } + + protected function runFs( $arguments ) + { + if ( !is_array( $arguments ) || empty( $arguments )) { + return false; + } + + $command = 'LC_ALL=C ' . escapeshellarg( $this->afsUtils . '/fs' ); + foreach ( $arguments as $argument ) { + $command .= ' ' . escapeshellarg( $argument ); + } + + if ( !function_exists( 'exec' )) { + $this->lastFsStatus = 126; + return false; + } + + $output = array(); + $status = 0; + exec( $command . ' 2>&1', $output, $status ); + $this->lastFsStatus = $status; + return implode( "\n", $output ); + } + + /* + * List the contents of a folder as a set of javascript + * variable declarations. + * + */ + public function get_foldercontents_js( $showHidden=false ) + { + $id = 0; + $files = ''; + + if ( is_file( $this->path )) { + $path = dirname( $this->path ); + } else { + $path = $this->path; + } + + if ( !$this->makePathAFSlocal( $path )) { + $this->errorMsg = "Unable to view: $this->path."; + return false; + } + + if ( !@is_dir( '.' )) { + @chdir( $this->startCWD ); + return false; + } + + // Open the path and read its contents + if ( !$dh = @opendir( '.' )) { + $this->errorMsg = "Unable to view: $this->path."; + @chdir( $this->startCWD ); + return false; + } + + while ( $filename = readdir( $dh )) { + clearstatcache(); + if ( !$fileStats = @lstat( $filename )) { + $modTime = ''; + $size = 0; + } else { + $modTime = $fileStats['mtime']; + $size = $fileStats['size']; + } + + //$mimeType = Mime::getMimeType( $filename ); + //$mimeIcon = Mime::getIcon( $mimeType, $filename ); + $filename = $this->escape_js( $filename ); + + $viewable = 0; + + //if ( Mime::getPreviewType( $mimeType ) || @is_dir( $filename )) { + //$viewable = 1; + //} + + if ( $showHidden || strpos( $filename, '.' ) !== 0 ) { + $files .= "files[$id]=new File('$filename', '$modTime', $size, " + . "'', '$mimeIcon', $viewable);\n"; + } + + $id++; + } + + closedir( $dh ); + @chdir( $this->startCWD ); + return $files; + } + + function get_foldername() + { + return basename( $this->path ); + } + + function get_returnToURI() + { + if ( !defined( 'FM_SELF_URL' )) { + return ''; + } + return ( FM_SELF_URL . + "?path=" . + urlencode($this->path) . + "&" . + "finishid=" . + $this->sid ); + } + + /* + * Return a string escaped for a javascript string literal. + */ + function escape_js( $string ) + { + $output = ""; + + $length = strlen( $string ); + for( $i=0; $i<$length; $i++ ) + { + $c = $string[$i]; + switch( $c ) + { + case '\'': + $output .= '\\\''; + break; + case '\\': + $output .= '\\\\'; + break; + case "\n": + $output .= '\\n'; + break; + case "\r": + $output .= '\\r'; + break; + default: + $output .= $c; + break; + } + } + + return $output; + } + + /* An initial check to make sure the path is in AFS. This is an initial + * check only. To avoid race conditions, other precaustions must be used. + * CAUTION: This method will be removed in the next major release. + */ + protected function pathSecurity( $path='' ) + { + if ( !$this->afsAvailable || empty( $path ) + || !is_array( $this->afsStat )) { + return false; + } + + /* The path is only safe if we're in AFS at the end of it. + * This test is raceable - so we should check again before sending + * anything to the client. + */ + clearstatcache(); + + if ( !$pathStat = @stat( $path )) { + return false; + } + + if ( $this->afsStat["dev"] != $pathStat["dev"] ) { + return false; + } + + // Remove the final / in the target path if it exists + return rtrim( $path, '/' ); + } + + + public function makePathAFSlocal( $path ) + { + if ( !$this->afsAvailable || !is_array( $this->afsStat )) { + $this->errorMsg = 'AFS is not mounted.'; + return false; + } + + if ( !@chdir( $path )) { + $this->errorMsg = "Couldn't change directory"; + return false; + } + + clearstatcache(); + $stat = @stat( '.' ); + if ( !is_array( $stat ) || $this->afsStat['dev'] != $stat['dev'] ) { + $this->errorMsg = "Path not in AFS"; + @chdir( $this->startCWD ); + return false; + } + + return true; + } + + + // Checks to see if there is a folder at the current path + function folderExists( $path='' ) + { + $path = ( $path ) ? $path : $this->path; + return is_dir( $path ); + } + + + public function linkSafeFileExists( $path ) + { + clearstatcache(); + + if ( is_array( @lstat( $path ))) { + return true; + } else { + return false; + } + } + + + // Set the afs path used inside the class + function setPath( $path='' ) + { + $safePath = $this->pathSecurity( $path ); + if ( !$safePath ) { + $this->path = ''; + $this->errorMsg = 'Path not in AFS'; + return false; + } + + $this->path = $safePath; + return true; + } + + public function isAvailable() + { + return $this->afsAvailable; + } + + // Makes each piece of a file path clickable + function pathDisplay() + { + if ( empty( $this->path )) { + return ''; + } + + $path = preg_replace( '/^\/afs\//', '', $this->path ); + $path = explode( '/', $path ); + $lastItem = array_pop( $path ); + $pathDisp = '/afs'; + $pathURI = '/afs'; + $lastDisp = ''; + $lastURI = ''; + + foreach ( $path as $piece ) { + $pathURI .= "/$piece"; + $pathDisp .= "/" + . htmlentities( $piece ) . ""; + } + + $pathURI .= $lastURI; + $pathDisp .= $lastDisp; + + return $pathDisp . '/' . htmlentities( $lastItem ); + } + + // Make smarty template variable assignments + function make_smarty_assignments(&$smart) + { + $smart->assign( 'path_url', urlencode($this->path)); + $smart->assign( 'parentPath', urlencode($this->parPath )); + $smart->assign( 'location', $this->pathDisplay()); + } + + function get_js_declarations() + { + $retstr = ""; + + $retstr .= $this->js_var( "path", $this->path ); + $retstr .= $this->js_var( "foldername", $this->get_foldername( )); + $retstr .= $this->js_var( "folderIcon", "" ); + $retstr .= $this->js_var( "homepath", $this->path ); + $retstr .= $this->js_var( "sid", $this->sid ); + $retstr .= $this->js_var( "returnToURI", $this->get_returnToURI( )); + $retstr .= $this->js_var( "adminPriv", $this->adminPriv); + $retstr .= $this->js_var( "deletePriv", $this->deletePriv); + $retstr .= $this->js_var( "insertPriv", $this->insertPriv ); + $retstr .= $this->js_var( "lockPriv", $this->lockPriv ); + $retstr .= $this->js_var( "readPriv", $this->readPriv ); + $retstr .= $this->js_var( "lookupPriv", $this->lookupPriv ); + $retstr .= $this->js_var( "writePriv", $this->writePriv ); + $retstr .= "files = new Array();\n"; + $retstr .= $this->get_foldercontents_js( true ); + + return $retstr; + } + + private function js_var( $varname, $contents ) + { + $retstr = ""; + $retstr .= "var $varname = " . + ( is_string( $contents ) ? + "'" . $this->escape_js( $contents ) . "'" + : $contents ) + . ";\n"; + return $retstr; + } + +} + +class AfsProductionReadiness +{ + const PRODUCTION_PROFILE = 'afs-descriptor-v1'; + + const LOCAL_ONLY_CONTENT_SECURITY_POLICY = "default-src 'none'; " . + "base-uri 'none'; connect-src 'self'; font-src 'self'; " . + "form-action 'self'; frame-ancestors 'none'; frame-src 'none'; " . + "img-src 'self' data:; media-src 'self'; object-src 'none'; " . + "script-src 'self'; style-src 'self'; worker-src 'self'"; + + public static function validateProductionProfile( $state, &$error=null ) + { + $keys = array( + 'profile', 'afs_enabled', 'external_auth', 'request_identity', + 'local_auth', 'local_users_empty', 'settings_enabled', + 'embed_enabled', 'direct_links_enabled', + 'raw_previews_enabled', 'url_upload_enabled', + 'root_url', 'self_url', + 'data_root', 'asset_manifest_sha256', + 'expected_factory_class', 'expected_factory_id', + 'expected_provider_class', 'expected_provider_id' + ); + if ( !is_array( $state ) || count( $state ) !== count( $keys ) + || array_diff_key( array_flip( $keys ), $state ) + || array_diff_key( $state, array_flip( $keys ))) { + $error = 'The AFS production profile is missing or malformed.'; + return false; + } + + $fixed = array( + 'profile' => self::PRODUCTION_PROFILE, + 'afs_enabled' => true, + 'external_auth' => true, + 'local_auth' => false, + 'local_users_empty' => true, + 'settings_enabled' => false, + 'embed_enabled' => false, + 'direct_links_enabled' => false, + 'raw_previews_enabled' => false, + 'url_upload_enabled' => false, + 'root_url' => '' + ); + foreach ( $fixed as $key => $expected ) { + if ( $state[$key] !== $expected ) { + $error = 'Invalid AFS production profile setting: ' . $key; + return false; + } + } + + if ( !is_string( $state['request_identity'] ) + || $state['request_identity'] === '' + || trim( $state['request_identity'] ) !== $state['request_identity'] + || preg_match( '/[\x00-\x1f\x7f]/', $state['request_identity'] )) { + $error = 'AFS production requires a trusted external identity.'; + return false; + } + if ( !is_string( $state['self_url'] ) + || $state['self_url'] === '' + || substr( $state['self_url'], 0, 1 ) !== '/' + || strpos( $state['self_url'], '//' ) !== false + || preg_match( '/[\x00\r\n?#]/', $state['self_url'] )) { + $error = 'AFS production requires a root-relative controller URL.'; + return false; + } + if ( !is_string( $state['data_root'] ) + || strpos( $state['data_root'], '/afs/' ) !== 0 + || rtrim( $state['data_root'], '/' ) !== $state['data_root'] + || strpos( $state['data_root'], '\\' ) !== false + || preg_match( '/[\x00-\x1f\x7f]/', $state['data_root'] )) { + $error = 'AFS production requires one absolute data root below /afs.'; + return false; + } + foreach ( explode( '/', substr( $state['data_root'], 5 )) + as $segment ) { + if ( $segment === '' || $segment === '.' || $segment === '..' ) { + $error = 'The AFS production data root is not normalized.'; + return false; + } + } + if ( !is_string( $state['asset_manifest_sha256'] ) + || !preg_match( '/^[a-f0-9]{64}$/', + $state['asset_manifest_sha256'] )) { + $error = 'AFS production requires a lowercase manifest SHA-256.'; + return false; + } + foreach ( array( + 'expected_factory_class', 'expected_factory_id', + 'expected_provider_class', 'expected_provider_id' + ) as $key ) { + if ( !is_string( $state[$key] ) || $state[$key] === '' + || strlen( $state[$key] ) > 255 + || !preg_match( '/^[A-Za-z0-9_.:@+\\\\\/-]+$/', + $state[$key] )) { + $error = 'Invalid AFS production identity setting: ' . $key; + return false; + } + } + return true; + } + + public static function applicationTemplatesSupportStrictCsp() + { + // Tiny File Manager 2.6 still emits inline script/style blocks and + // event-handler attributes. AFS production must remain unavailable + // until those templates use reviewed external assets plus nonces or + // hashes; accepting unsafe-inline/unsafe-eval is not an alternative. + return false; + } + + public static function validateContentSecurityPolicy( $policy, &$error=null ) + { + if ( !is_string( $policy ) || trim( $policy ) === '' ) { + $error = 'AFS production mode requires a reviewed ' . + 'Content-Security-Policy.'; + return false; + } + if ( preg_match( '/[\x00\r\n]/', $policy )) { + $error = 'Invalid Content-Security-Policy configuration.'; + return false; + } + if ( $policy !== self::LOCAL_ONLY_CONTENT_SECURITY_POLICY ) { + $error = 'The AFS CSP must match the canonical 13-directive ' . + 'application policy exactly.'; + return false; + } + + $required = array( + 'default-src', 'base-uri', 'connect-src', 'font-src', + 'form-action', 'frame-ancestors', 'frame-src', 'img-src', + 'media-src', 'object-src', 'script-src', 'style-src', + 'worker-src' + ); + $directives = array(); + foreach ( explode( ';', $policy ) as $clause ) { + $clause = trim( $clause ); + if ( $clause === '' ) { + continue; + } + $parts = preg_split( '/\s+/', $clause ); + $name = strtolower( array_shift( $parts )); + if ( !preg_match( '/^[a-z][a-z0-9-]*$/', $name ) + || isset( $directives[$name] )) { + $error = 'The CSP contains an invalid or duplicate directive.'; + return false; + } + if ( !in_array( $name, $required, true )) { + $error = 'The CSP contains an unreviewed directive: ' . $name; + return false; + } + $directives[$name] = $parts; + } + + foreach ( $required as $name ) { + if ( !isset( $directives[$name] ) + || empty( $directives[$name] )) { + $error = 'The CSP is missing required directive ' . $name . '.'; + return false; + } + if ( !self::validateLocalCspSources( + $name, $directives[$name], $error )) { + return false; + } + } + return true; + } + + protected static function validateLocalCspSources( $name, $sources, + &$error ) + { + if ( empty( $sources )) { + $error = $name . ' must contain at least one CSP source.'; + return false; + } + if (( $name === 'object-src' || $name === 'frame-src' ) + && $sources !== array( "'none'" )) { + $error = $name . " must be exactly 'none' in AFS mode."; + return false; + } + if ( in_array( "'none'", $sources, true ) && count( $sources ) !== 1 ) { + $error = "'none' cannot be combined with other CSP sources."; + return false; + } + + foreach ( $sources as $source ) { + if ( $source === "'self'" || $source === "'none'" ) { + continue; + } + if ( $source === 'data:' + && ( $name === 'img-src' || $name === 'font-src' )) { + continue; + } + if ( preg_match( "/^'(?:nonce-[A-Za-z0-9+\/_-]+=*|sha(?:256|384|512)-[A-Za-z0-9+\/=]+)'$/", + $source ) + && ( $name === 'script-src' || $name === 'style-src' )) { + continue; + } + $error = 'Remote, wildcard, or unsupported CSP source in ' . + $name . ': ' . $source; + return false; + } + + if ( in_array( $name, array( + 'default-src', 'script-src', 'style-src', 'img-src', + 'font-src', 'connect-src', 'media-src', 'base-uri', + 'form-action', 'worker-src' ), true ) + && !in_array( "'self'", $sources, true ) + && !in_array( "'none'", $sources, true )) { + $error = $name . " must contain 'self' or 'none'."; + return false; + } + return true; + } + + public static function buildLocalAssetTags( $manifest, $assetRoot, + &$error=null ) + { + $types = array( + 'css-bootstrap' => 'style', + 'css-dropzone' => 'style', + 'css-font-awesome' => 'style', + 'css-highlightjs' => 'style', + 'js-ace' => 'script', + 'js-bootstrap' => 'script', + 'js-dropzone' => 'script', + 'js-jquery' => 'script', + 'js-jquery-datatables' => 'script', + 'js-highlightjs' => 'script' + ); + if ( !is_array( $manifest ) + || count( $manifest ) !== count( $types ) + || array_diff_key( $manifest, $types ) + || array_diff_key( $types, $manifest )) { + $error = 'The AFS local asset manifest must contain exactly the ' . + 'required script and style keys.'; + return false; + } + + $tags = array(); + foreach ( $types as $key => $expectedType ) { + $entry = $manifest[$key]; + if ( !is_array( $entry )) { + $error = 'Invalid asset manifest row for ' . $key . '.'; + return false; + } + $allowedFields = array( + 'type' => true, 'path' => true, 'sha256' => true, + 'license' => true, 'defer' => true + ); + if ( array_diff_key( $entry, $allowedFields ) + || !isset( $entry['type'], $entry['path'], $entry['sha256'], + $entry['license'], $entry['defer'] ) + || $entry['type'] !== $expectedType + || !is_bool( $entry['defer'] ) + || ( $expectedType === 'style' && $entry['defer'] !== false )) { + $error = 'Invalid typed asset fields for ' . $key . '.'; + return false; + } + if ( !in_array( $entry['license'], array( + 'MIT', 'BSD-3-Clause', 'Apache-2.0', 'OFL-1.1' + ), true )) { + $error = 'Unreviewed asset license for ' . $key . '.'; + return false; + } + if ( !self::validateLocalAsset( + $entry['path'], $assetRoot, $entry['sha256'], $error )) { + return false; + } + + $url = htmlspecialchars( + $entry['path'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); + if ( $expectedType === 'style' ) { + $tags[$key] = ''; + } else { + $defer = !empty( $entry['defer'] ) ? ' defer' : ''; + $tags[$key] = ''; + } + } + $tags['pre-jsdelivr'] = ''; + $tags['pre-cloudflare'] = ''; + return $tags; + } + + public static function buildLocalAssetTagsFromManifestFile( + $manifestFile, $assetRoot, $manifestSha256, &$error=null ) + { + if ( !is_string( $manifestFile ) || $manifestFile === '' + || trim( $manifestFile ) !== $manifestFile + || substr( $manifestFile, 0, 1 ) === '/' + || strpos( $manifestFile, '%' ) !== false + || strpos( $manifestFile, '?' ) !== false + || strpos( $manifestFile, '#' ) !== false + || strpos( $manifestFile, '\\' ) !== false + || preg_match( '/[\x00-\x20\x7f]/', $manifestFile )) { + $error = 'Invalid AFS asset-manifest path.'; + return false; + } + foreach ( explode( '/', $manifestFile ) as $segment ) { + if ( $segment === '' || $segment === '.' || $segment === '..' ) { + $error = 'Invalid AFS asset-manifest path component.'; + return false; + } + } + + $root = is_string( $assetRoot ) ? @realpath( $assetRoot ) : false; + if ( $root === false ) { + $error = 'The AFS asset root is unavailable.'; + return false; + } + $candidate = $root; + foreach ( explode( '/', $manifestFile ) as $segment ) { + $candidate .= '/' . $segment; + $component = @lstat( $candidate ); + if ( !is_array( $component ) + || ( isset( $component['mode'] ) + && ( $component['mode'] & 0170000 ) === 0120000 )) { + $error = 'The AFS asset manifest cannot contain symlinks.'; + return false; + } + } + $resolved = @realpath( $candidate ); + $root = rtrim( str_replace( '\\', '/', $root ), '/' ); + $resolved = $resolved === false ? false + : str_replace( '\\', '/', $resolved ); + if ( $resolved === false + || strpos( $resolved, $root . '/' ) !== 0 + || !is_file( $resolved ) || !is_readable( $resolved )) { + $error = 'The AFS asset manifest is unavailable or outside its root.'; + return false; + } + $raw = @file_get_contents( $resolved ); + if ( !is_string( $raw ) || strlen( $raw ) > 1048576 ) { + $error = 'Unable to read the AFS asset manifest.'; + return false; + } + if ( !is_string( $manifestSha256 ) + || !preg_match( '/^[a-f0-9]{64}$/', $manifestSha256 ) + || !hash_equals( $manifestSha256, hash( 'sha256', $raw ))) { + $error = 'AFS asset-manifest digest mismatch.'; + return false; + } + $decoded = json_decode( $raw, true ); + if ( !is_array( $decoded ) + || count( $decoded ) !== 2 + || !array_key_exists( 'version', $decoded ) + || !array_key_exists( 'assets', $decoded ) + || $decoded['version'] !== 1 + || !is_array( $decoded['assets'] )) { + $error = 'Invalid AFS asset-manifest schema.'; + return false; + } + return self::buildLocalAssetTags( + $decoded['assets'], $root, $error ); + } + + public static function validateLocalAsset( $reference, $assetRoot, + $sha256, &$error=null ) + { + if ( !is_string( $reference ) || trim( $reference ) !== $reference + || $reference === '' || substr( $reference, 0, 1 ) === '/' + || strpos( $reference, '%' ) !== false + || strpos( $reference, '?' ) !== false + || strpos( $reference, '#' ) !== false + || strpos( $reference, '\\' ) !== false + || preg_match( '/[\x00-\x20\x7f]/', $reference ) + || preg_match( '/^[a-z][a-z0-9+.-]*:/i', $reference )) { + $error = 'Invalid local asset path.'; + return false; + } + foreach ( explode( '/', $reference ) as $segment ) { + if ( $segment === '' || $segment === '.' || $segment === '..' ) { + $error = 'Invalid local asset path component.'; + return false; + } + } + if ( !is_string( $sha256 ) + || !preg_match( '/^[a-f0-9]{64}$/', $sha256 )) { + $error = 'Each local asset requires a reviewed SHA-256 digest.'; + return false; + } + + $root = is_string( $assetRoot ) ? @realpath( $assetRoot ) : false; + $candidatePath = $root !== false ? $root : ''; + if ( $root !== false ) { + foreach ( explode( '/', $reference ) as $segment ) { + $candidatePath .= '/' . $segment; + $component = @lstat( $candidatePath ); + if ( !is_array( $component ) + || ( isset( $component['mode'] ) + && ( $component['mode'] & 0170000 ) === 0120000 )) { + $error = 'Local asset paths cannot contain symbolic links: ' . + $reference; + return false; + } + } + } + $candidate = $root !== false ? @realpath( $candidatePath ) : false; + if ( $root === false || $candidate === false ) { + $error = 'Required local asset is missing: ' . $reference; + return false; + } + $root = rtrim( str_replace( '\\', '/', $root ), '/' ); + $candidate = str_replace( '\\', '/', $candidate ); + $withinRoot = $candidate === $root + || ( $root === '' + ? strpos( $candidate, '/' ) === 0 + : strpos( $candidate, $root . '/' ) === 0 ); + if ( !$withinRoot || !is_file( $candidate ) + || !is_readable( $candidate )) { + $error = 'Local asset is unavailable or outside the configured ' . + 'asset root: ' . $reference; + return false; + } + $actual = @hash_file( 'sha256', $candidate ); + if ( !is_string( $actual ) + || !hash_equals( $sha256, $actual )) { + $error = 'Local asset digest mismatch: ' . $reference; + return false; + } + return true; + } + +} + +/* + * Path-policy preview for the Tiny File Manager data-plane provider API. + * + * The historical Afs helpers above only prove that an object is on the same + * client device as /afs. They do not constrain an operation to the configured + * Tiny File Manager root. This facade owns both resolution and I/O so an AFS + * failure can never fall through to a generic filesystem helper. It is not a + * production security boundary: PHP 7.4 cannot bind a pathname walk and later + * mutation to one directory descriptor. A production implementation must use + * an openat2-style RESOLVE_BENEATH/RESOLVE_NO_MAGICLINKS boundary (initially + * RESOLVE_NO_SYMLINKS), or an equivalent native broker. + * + * POSIX symbolic links and kernel mount points below the configured root are + * rejected. AFS volume mount points are different objects: ordinary logical + * traversal through them is allowed, but recursive copy/delete stops at a + * child volume boundary. A user can navigate into that volume and start a new + * operation there. The exact mutation semantics still require live YFS tests. + */ +class AfsDataPlane extends Afs implements AfsDataPlaneProvider +{ + protected $dataRoot = ''; + protected $dataRootDevice = null; + protected $dataRootIdentity = array(); + protected $kernelMountPoints = null; + protected $volumeMountCache = array(); + protected $identityCache = array(); + protected $crossedVolumeMounts = array(); + + public function isProductionReady() + { + return false; + } + + public function getReadinessFailure() + { + return 'The bundled PHP AFS provider is pathname-based. Configure a ' . + 'descriptor-backed AfsDataPlaneProvider before production use.'; + } + + public function getSecurityBoundary() + { + return 'pathname-preview'; + } + + public function getProviderIdentity() + { + return 'tinyfilemanager-afs-pathname-preview-v1'; + } + + public function getCredentialIdentity() + { + return $this->credentialIdentity; + } + + public function initializeDataPlane( $root ) + { + if ( !$this->isAvailable() || !is_array( $this->afsStat )) { + $this->errorMsg = 'AFS data-plane guard is unavailable.'; + return false; + } + + $root = $this->normalizeAbsolutePath( $root ); + if ( $root === false ) { + $this->errorMsg = 'Invalid AFS data root.'; + return false; + } + + $rootLstat = $this->pathLstat( $root ); + $rootStat = $this->pathStat( $root ); + $rootReal = $this->pathRealpath( $root ); + if ( !is_array( $rootLstat ) || !is_array( $rootStat ) + || $this->statIsLink( $rootLstat ) + || !$this->statIsDirectory( $rootStat ) + || $rootReal === false ) { + $this->errorMsg = 'The configured root is not a real AFS directory.'; + return false; + } + + $rootReal = $this->normalizeAbsolutePath( $rootReal ); + if ( $rootReal === false ) { + $this->errorMsg = 'Unable to resolve the configured AFS root.'; + return false; + } + + $mounts = $this->loadKernelMountPoints(); + if ( !is_array( $mounts )) { + $this->errorMsg = 'Unable to inspect the kernel mount table.'; + return false; + } + + $this->dataRoot = $rootReal; + $this->dataRootDevice = $rootStat['dev']; + $this->kernelMountPoints = array_fill_keys( $mounts, true ); + + $identity = $this->probeAfsIdentity( $rootReal, false, true ); + if ( !is_array( $identity )) { + $this->dataRoot = ''; + $this->errorMsg = 'Unable to identify the configured AFS root.'; + return false; + } + + $this->dataRootIdentity = $identity; + return true; + } + + public function getDataRoot() + { + return $this->dataRoot; + } + + public function getCrossedVolumeMounts() + { + return $this->crossedVolumeMounts; + } + + public function archivesSupported() + { + // ZipArchive::extractTo(), PharData::extractTo(), and the upstream + // archive walkers own their own pathname traversal. They must not be + // used in AFS mode until per-entry guarded implementations exist. + return false; + } + + public function resolveExistingPath( $path, $type='any' ) + { + return $this->resolveConfinedPath( $path, $type, false ); + } + + public function resolveWritePath( $path, $allowExisting=true ) + { + $path = $this->normalizeAbsolutePath( $path ); + if ( $path === false || !$this->pathWithinRoot( $path ) + || $path === $this->dataRoot ) { + $this->errorMsg = 'Write target is outside the configured AFS root.'; + return false; + } + + $existing = $this->pathLstat( $path ); + if ( is_array( $existing )) { + if ( !$allowExisting ) { + $this->errorMsg = 'The destination already exists.'; + return false; + } + return $this->resolveConfinedPath( $path, 'file', false ); + } + + $leaf = basename( $path ); + if ( !$this->validLeafName( $leaf )) { + $this->errorMsg = 'Invalid AFS destination name.'; + return false; + } + + $parent = $this->resolveConfinedPath( dirname( $path ), 'dir', false ); + if ( $parent === false ) { + return false; + } + + return $parent . '/' . $leaf; + } + + public function inspectPath( $path, $allowLinkObject=false ) + { + $path = $allowLinkObject + ? $this->resolveObjectPath( $path ) + : $this->resolveExistingPath( $path ); + if ( $path === false ) { + return false; + } + + $lstat = $this->pathLstat( $path ); + if ( !is_array( $lstat )) { + return false; + } + if ( $this->statIsLink( $lstat )) { + if ( !$allowLinkObject ) { + return false; + } + $target = @readlink( $path ); + if ( $target === false ) { + return false; + } + return array( + 'path' => $path, + 'type' => 'link', + 'size' => isset( $lstat['size'] ) ? $lstat['size'] : 0, + 'mtime' => isset( $lstat['mtime'] ) ? $lstat['mtime'] : 0, + 'mode' => isset( $lstat['mode'] ) ? $lstat['mode'] : 0, + 'link_target' => $target + ); + } + + $stat = $this->pathStat( $path ); + if ( !is_array( $stat )) { + return false; + } + if ( $this->statIsDirectory( $stat )) { + $type = 'dir'; + } elseif ( $this->statIsFile( $stat )) { + $type = 'file'; + } else { + return false; + } + return array( + 'path' => $path, + 'type' => $type, + 'size' => isset( $stat['size'] ) ? $stat['size'] : 0, + 'mtime' => isset( $stat['mtime'] ) ? $stat['mtime'] : 0, + 'mode' => isset( $stat['mode'] ) ? $stat['mode'] : 0, + 'link_target' => false + ); + } + + public function listDirectory( $path ) + { + $path = $this->resolveExistingPath( $path, 'dir' ); + if ( $path === false ) { + return false; + } + + $items = @scandir( $path ); + if ( !is_array( $items )) { + $this->errorMsg = 'Unable to list the AFS directory.'; + return false; + } + + $safe = array(); + foreach ( $items as $item ) { + if ( $item === '.' || $item === '..' ) { + continue; + } + if ( $this->inspectPath( + $path . '/' . $item, true ) !== false ) { + $safe[] = $item; + } + } + return $safe; + } + + public function searchFiles( $path, $filter='' ) + { + $path = $this->resolveExistingPath( $path, 'dir' ); + if ( $path === false ) { + return false; + } + + $results = array(); + if ( !$this->searchDirectory( $path, $path, (string)$filter, $results )) { + return false; + } + return $results; + } + + public function openRead( $path ) + { + $path = $this->resolveExistingPath( $path, 'file' ); + if ( $path === false ) { + return false; + } + + $handle = @fopen( $path, 'rb' ); + if ( $handle === false || !$this->validateOpenHandle( $handle, $path )) { + if ( is_resource( $handle )) { + @fclose( $handle ); + } + $this->errorMsg = 'Unable to open a confined AFS file.'; + return false; + } + + return $handle; + } + + public function readContents( $path ) + { + $handle = $this->openRead( $path ); + if ( $handle === false ) { + return false; + } + + $contents = ''; + $ok = true; + while ( !feof( $handle )) { + $buffer = fread( $handle, 1024 * 1024 ); + if ( $buffer === false ) { + $ok = false; + break; + } + $contents .= $buffer; + } + if ( !@fclose( $handle )) { + $ok = false; + } + + if ( !$ok ) { + $this->errorMsg = 'Unable to read the complete AFS file.'; + return false; + } + return $contents; + } + + public function detectMimeType( $path ) + { + $handle = $this->openRead( $path ); + if ( $handle === false ) { + return false; + } + $sample = @fread( $handle, 262144 ); + $closed = @fclose( $handle ); + if ( $sample === false || !$closed ) { + $this->errorMsg = 'Unable to sample the confined AFS file.'; + return false; + } + + if ( function_exists( 'finfo_open' ) + && function_exists( 'finfo_buffer' )) { + $finfo = @finfo_open( FILEINFO_MIME_TYPE ); + if ( $finfo !== false ) { + $mime = @finfo_buffer( $finfo, $sample ); + if ( PHP_VERSION_ID < 80000 ) { + @finfo_close( $finfo ); + } + if ( is_string( $mime ) && $mime !== '' ) { + return $mime; + } + } + } + return 'application/octet-stream'; + } + + public function readAcl( $path='' ) + { + $path = $this->resolveExistingPath( $path ); + return $path !== false ? parent::readAcl( $path ) : false; + } + + public function changeAclEntries( $entries, $path='', $negative=false ) + { + $path = $this->resolveExistingPath( $path ); + return $path !== false + ? parent::changeAclEntries( $entries, $path, $negative ) : false; + } + + public function getACLAccess( $path ) + { + $path = $this->resolveExistingPath( $path ); + return $path !== false ? parent::getACLAccess( $path ) : ''; + } + + public function createFile( $path ) + { + $path = $this->resolveWritePath( $path, false ); + if ( $path === false ) { + return false; + } + + $handle = @fopen( $path, 'x+b' ); + if ( $handle === false || !$this->validateOpenHandle( $handle, $path )) { + if ( is_resource( $handle )) { + @fclose( $handle ); + } + @unlink( $path ); + $this->errorMsg = 'Unable to create a confined AFS file.'; + return false; + } + + $ok = @fflush( $handle ); + if ( !@fclose( $handle )) { + $ok = false; + } + if ( !$ok ) { + @unlink( $path ); + $this->errorMsg = 'Unable to close the new AFS file.'; + return false; + } + return $this->resolveExistingPath( $path, 'file' ) !== false; + } + + public function writeFile( $path, $contents ) + { + $path = $this->resolveWritePath( $path, true ); + if ( $path === false ) { + return false; + } + + $newFile = !is_array( $this->pathLstat( $path )); + $handle = @fopen( $path, $newFile ? 'x+b' : 'c+b' ); + if ( $handle === false || !$this->validateOpenHandle( $handle, $path )) { + if ( is_resource( $handle )) { + @fclose( $handle ); + } + if ( $newFile ) { + @unlink( $path ); + } + $this->errorMsg = 'Unable to open the AFS write target.'; + return false; + } + + $ok = @ftruncate( $handle, 0 ) && @rewind( $handle ); + if ( $ok ) { + $ok = $this->writeAll( $handle, (string)$contents ); + } + if ( $ok ) { + $ok = @fflush( $handle ); + } + if ( !@fclose( $handle )) { + $ok = false; + } + + if ( !$ok ) { + if ( $newFile ) { + @unlink( $path ); + } + $this->errorMsg = 'Unable to write the complete AFS file.'; + return false; + } + return $this->resolveExistingPath( $path, 'file' ) !== false; + } + + public function importFile( $source, $destination, $overwrite=true, + $append=false ) + { + $sourceHandle = @fopen( $source, 'rb' ); + $sourceStat = is_resource( $sourceHandle ) ? @fstat( $sourceHandle ) : false; + if ( $sourceHandle === false || !is_array( $sourceStat ) + || !$this->statIsFile( $sourceStat )) { + if ( is_resource( $sourceHandle )) { + @fclose( $sourceHandle ); + } + $this->errorMsg = 'Unable to open the import source.'; + return false; + } + + $destination = $this->resolveWritePath( $destination, $overwrite ); + if ( $destination === false ) { + @fclose( $sourceHandle ); + return false; + } + + $newFile = !is_array( $this->pathLstat( $destination )); + $destinationHandle = @fopen( + $destination, $newFile ? 'x+b' : 'c+b' ); + if ( $destinationHandle === false + || !$this->validateOpenHandle( $destinationHandle, $destination )) { + @fclose( $sourceHandle ); + if ( is_resource( $destinationHandle )) { + @fclose( $destinationHandle ); + } + if ( $newFile ) { + @unlink( $destination ); + } + $this->errorMsg = 'Unable to open the AFS import target.'; + return false; + } + + $ok = true; + if ( $append ) { + $ok = @fseek( $destinationHandle, 0, SEEK_END ) === 0; + } else { + $ok = @ftruncate( $destinationHandle, 0 ) + && @rewind( $destinationHandle ); + } + + while ( $ok && !feof( $sourceHandle )) { + $buffer = fread( $sourceHandle, 1024 * 1024 ); + if ( $buffer === false ) { + $ok = false; + break; + } + if ( !$this->writeAll( $destinationHandle, $buffer )) { + $ok = false; + } + } + if ( $ok ) { + $ok = @fflush( $destinationHandle ); + } + if ( !@fclose( $sourceHandle )) { + $ok = false; + } + if ( !@fclose( $destinationHandle )) { + $ok = false; + } + + if ( !$ok ) { + if ( $newFile ) { + @unlink( $destination ); + } + $this->errorMsg = 'Unable to import the complete file into AFS.'; + return false; + } + return $this->resolveExistingPath( $destination, 'file' ) !== false; + } + + public function makeDirectory( $path, $recursive=true ) + { + $path = $this->normalizeAbsolutePath( $path ); + if ( $path === false || !$this->pathWithinRoot( $path )) { + $this->errorMsg = 'Directory target is outside the configured AFS root.'; + return false; + } + if ( $path === $this->dataRoot ) { + return true; + } + + $relative = substr( $path, strlen( $this->dataRoot ) + 1 ); + $segments = explode( '/', $relative ); + if ( !$recursive && count( $segments ) !== 1 + && !is_array( $this->pathLstat( dirname( $path )))) { + $this->errorMsg = 'The parent AFS directory does not exist.'; + return false; + } + + $current = $this->dataRoot; + foreach ( $segments as $segment ) { + if ( !$this->validLeafName( $segment )) { + return false; + } + $current .= '/' . $segment; + if ( is_array( $this->pathLstat( $current ))) { + if ( $this->resolveExistingPath( $current, 'dir' ) === false ) { + return false; + } + continue; + } + if ( !@mkdir( $current, 0755, false )) { + $this->errorMsg = 'Unable to create the AFS directory.'; + return false; + } + if ( $this->resolveExistingPath( $current, 'dir' ) === false ) { + @rmdir( $current ); + return false; + } + } + return true; + } + + public function copyPath( $source, $destination, $update=true, + $force=true ) + { + $source = $this->resolveExistingPath( $source ); + if ( $source === false || !$this->preflightRecursiveTree( $source )) { + return false; + } + + return $this->copyResolvedPath( + $source, $destination, $update, $force ); + } + + public function renamePath( $source, $destination ) + { + $source = $this->resolveObjectPath( $source ); + if ( $source === false || $source === $this->dataRoot ) { + return false; + } + + $sourceInfo = $this->inspectPath( $source, true ); + if ( $sourceInfo === false ) { + return false; + } + if ( $sourceInfo['type'] === 'dir' ) { + $mount = $this->probeAfsVolumeMountPoint( $source ); + if ( $mount === null || $mount !== false ) { + $this->errorMsg = 'AFS volume mount objects cannot be renamed here.'; + return false; + } + } + + if ( is_array( $this->pathLstat( $destination ))) { + $this->errorMsg = 'The destination already exists.'; + return null; + } + $destination = $this->resolveWritePath( $destination, false ); + if ( $destination === false ) { + return false; + } + + if ( !@rename( $source, $destination )) { + $this->errorMsg = 'Unable to rename the AFS object.'; + return false; + } + + $destinationInfo = $this->inspectPath( $destination, true ); + if ( $destinationInfo === false + || $destinationInfo['type'] !== $sourceInfo['type'] ) { + @rename( $destination, $source ); + $this->errorMsg = 'AFS rename post-validation failed.'; + return false; + } + return true; + } + + public function removePath( $path ) + { + $path = $this->resolveObjectPath( $path ); + $info = $path !== false ? $this->inspectPath( $path, true ) : false; + if ( is_array( $info ) && $info['type'] === 'link' ) { + return @unlink( $path ); + } + if ( $path === false || $path === $this->dataRoot + || !$this->preflightRecursiveTree( $path, true )) { + return false; + } + return $this->removeResolvedPath( $path ); + } + + protected function resolveObjectPath( $path ) + { + $path = $this->normalizeAbsolutePath( $path ); + if ( $path === false || !$this->pathWithinRoot( $path )) { + $this->errorMsg = 'Object path is outside the configured AFS root.'; + return false; + } + if ( $path === $this->dataRoot ) { + return $this->dataRoot; + } + $leaf = basename( $path ); + if ( !$this->validLeafName( $leaf )) { + return false; + } + $parent = $this->resolveExistingPath( dirname( $path ), 'dir' ); + if ( $parent === false ) { + return false; + } + $object = $parent . '/' . $leaf; + $lstat = $this->pathLstat( $object ); + if ( !is_array( $lstat )) { + return false; + } + if ( $this->statIsLink( $lstat )) { + return $object; + } + return $this->resolveExistingPath( $object ); + } + + protected function resolveConfinedPath( $path, $type, $allowMissing ) + { + if ( $this->dataRoot === '' ) { + $this->errorMsg = 'AFS data-plane guard is not initialized.'; + return false; + } + + $path = $this->normalizeAbsolutePath( $path ); + if ( $path === false || !$this->pathWithinRoot( $path )) { + $this->errorMsg = 'Path is outside the configured AFS root.'; + return false; + } + + if ( $path === $this->dataRoot ) { + if ( $type === 'file' ) { + return false; + } + return $this->dataRoot; + } + + $relative = substr( $path, strlen( $this->dataRoot ) + 1 ); + $segments = explode( '/', $relative ); + $current = $this->dataRoot; + $last = count( $segments ) - 1; + + foreach ( $segments as $index => $segment ) { + if ( !$this->validLeafName( $segment )) { + $this->errorMsg = 'Invalid AFS path component.'; + return false; + } + + $current .= '/' . $segment; + $lstat = $this->pathLstat( $current ); + if ( !is_array( $lstat )) { + if ( $allowMissing && $index === $last ) { + return $current; + } + $this->errorMsg = 'AFS path does not exist.'; + return false; + } + if ( $this->statIsLink( $lstat )) { + $this->errorMsg = 'POSIX symbolic links are not traversable in AFS mode.'; + return false; + } + + $stat = $this->pathStat( $current ); + $real = $this->pathRealpath( $current ); + if ( !is_array( $stat ) || $real === false ) { + $this->errorMsg = 'Unable to resolve the AFS path.'; + return false; + } + $real = $this->normalizeAbsolutePath( $real ); + if ( $real === false || !$this->pathWithinRoot( $real )) { + $this->errorMsg = 'Resolved path escapes the configured AFS root.'; + return false; + } + if ( $real !== $this->dataRoot + && $this->isKernelMountPoint( $real )) { + $this->errorMsg = 'Kernel mount points are not traversable in AFS mode.'; + return false; + } + + $identity = $this->probeAfsIdentity( $real, false ); + if ( !is_array( $identity )) { + $this->errorMsg = 'Unable to verify AFS object identity.'; + return false; + } + + $needsDirectory = $index < $last || $type === 'dir'; + if ( $needsDirectory && !$this->statIsDirectory( $stat )) { + $this->errorMsg = 'AFS path component is not a directory.'; + return false; + } + if ( $this->statIsDirectory( $stat )) { + $mount = $this->probeAfsVolumeMountPoint( $real ); + if ( $mount === null ) { + $this->errorMsg = 'Unable to classify an AFS volume mount point.'; + return false; + } + if ( $mount !== false ) { + $this->crossedVolumeMounts[$real] = array( + 'target' => $mount, + 'identity' => $identity + ); + } + } + $current = $real; + } + + $finalStat = $this->pathStat( $current ); + if ( $type === 'file' && !$this->statIsFile( $finalStat )) { + $this->errorMsg = 'AFS object is not a regular file.'; + return false; + } + if ( $type === 'dir' && !$this->statIsDirectory( $finalStat )) { + $this->errorMsg = 'AFS object is not a directory.'; + return false; + } + if ( $type === 'any' && !$this->statIsFile( $finalStat ) + && !$this->statIsDirectory( $finalStat )) { + $this->errorMsg = 'Unsupported AFS object type.'; + return false; + } + + return $current; + } + + protected function validateOpenHandle( $handle, $path ) + { + $handleStat = @fstat( $handle ); + $pathStat = $this->pathStat( $path ); + if ( !is_array( $handleStat ) || !is_array( $pathStat ) + || !$this->statIsFile( $handleStat ) + || $pathStat['dev'] != $handleStat['dev'] ) { + return false; + } + + if ( !empty( $handleStat['ino'] ) && !empty( $pathStat['ino'] ) + && $handleStat['ino'] != $pathStat['ino'] ) { + return false; + } + + unset( $this->identityCache['follow:' . $path] ); + unset( $this->identityCache['nofollow:' . $path] ); + return $this->resolveExistingPath( $path, 'file' ) === $path; + } + + protected function writeAll( $handle, $contents ) + { + $length = strlen( $contents ); + $written = 0; + while ( $written < $length ) { + $bytes = fwrite( $handle, substr( $contents, $written )); + if ( $bytes === false || $bytes === 0 ) { + return false; + } + $written += $bytes; + } + return true; + } + + protected function searchDirectory( $base, $path, $filter, &$results ) + { + $items = @scandir( $path ); + if ( !is_array( $items )) { + return false; + } + foreach ( $items as $item ) { + if ( $item === '.' || $item === '..' ) { + continue; + } + $child = $this->resolveExistingPath( $path . '/' . $item ); + if ( $child === false ) { + // A symlink, kernel mount, or otherwise unresolvable entry is + // not traversed and cannot leak search results. + continue; + } + $stat = $this->pathStat( $child ); + if ( $this->statIsDirectory( $stat )) { + $mount = $this->probeAfsVolumeMountPoint( $child ); + if ( $mount === null ) { + return false; + } + if ( $mount !== false ) { + // The caller can navigate into this child volume and start + // a new search there; a parent search never crosses it. + continue; + } + if ( !$this->searchDirectory( $base, $child, $filter, $results )) { + return false; + } + } elseif ( $this->statIsFile( $stat ) + && ( $filter === '' || stripos( $item, $filter ) !== false )) { + $results[] = array( + 'name' => $item, + 'type' => 'file', + 'path' => dirname( substr( $child, strlen( $base ))) + ); + } + } + return true; + } + + protected function preflightRecursiveTree( $path, $allowLinks=false ) + { + $info = $this->inspectPath( $path, $allowLinks ); + if ( $info === false ) { + return false; + } + $path = $info['path']; + if ( $info['type'] === 'link' ) { + return $allowLinks; + } + if ( $info['type'] === 'file' ) { + return true; + } + if ( $info['type'] !== 'dir' ) { + return false; + } + + $mount = $this->probeAfsVolumeMountPoint( $path ); + if ( $mount === null || $mount !== false ) { + $this->errorMsg = 'Recursive mutation stops at an AFS volume mount point.'; + return false; + } + + $items = @scandir( $path ); + if ( !is_array( $items )) { + return false; + } + foreach ( $items as $item ) { + if ( $item === '.' || $item === '..' ) { + continue; + } + $child = $path . '/' . $item; + if ( !$this->preflightRecursiveTree( $child, $allowLinks )) { + return false; + } + } + return true; + } + + protected function copyResolvedPath( $source, $destination, $update, $force ) + { + $stat = $this->pathStat( $source ); + if ( $this->statIsFile( $stat )) { + if ( is_array( $this->pathLstat( $destination )) && $update ) { + $destinationSafe = $this->resolveExistingPath( $destination, 'file' ); + if ( $destinationSafe === false + || @filemtime( $destinationSafe ) >= @filemtime( $source )) { + return false; + } + } + $sourceHandle = $this->openRead( $source ); + if ( $sourceHandle === false ) { + return false; + } + $temporary = @tempnam( sys_get_temp_dir(), 'tinyfm-afs-copy-' ); + if ( $temporary === false ) { + @fclose( $sourceHandle ); + return false; + } + $temporaryHandle = @fopen( $temporary, 'wb' ); + $ok = is_resource( $temporaryHandle ); + while ( $ok && !feof( $sourceHandle )) { + $buffer = fread( $sourceHandle, 1024 * 1024 ); + if ( $buffer === false + || !$this->writeAll( $temporaryHandle, $buffer )) { + $ok = false; + } + } + if ( $ok ) { + $ok = @fflush( $temporaryHandle ); + } + if ( is_resource( $temporaryHandle ) && !@fclose( $temporaryHandle )) { + $ok = false; + } + if ( !@fclose( $sourceHandle )) { + $ok = false; + } + if ( $ok ) { + $ok = $this->importFile( $temporary, $destination, true, false ); + } + if ( !@unlink( $temporary )) { + $ok = false; + } + return $ok; + } + + if ( !$this->statIsDirectory( $stat )) { + return false; + } + + $destinationNormalized = $this->normalizeAbsolutePath( $destination ); + if ( $destinationNormalized === false ) { + return false; + } + $destinationParent = $this->resolveExistingPath( + dirname( $destinationNormalized ), 'dir' ); + if ( $destinationParent === false + || $destinationParent === $source + || strpos( $destinationParent . '/', rtrim( $source, '/' ) . '/' ) === 0 ) { + $this->errorMsg = 'Cannot copy a directory inside itself.'; + return false; + } + + if ( is_array( $this->pathLstat( $destinationNormalized ))) { + if ( $this->resolveExistingPath( $destinationNormalized, 'dir' ) === false ) { + return false; + } + } elseif ( !$this->makeDirectory( $destinationNormalized, false )) { + return false; + } + + $items = @scandir( $source ); + if ( !is_array( $items )) { + return false; + } + foreach ( $items as $item ) { + if ( $item === '.' || $item === '..' ) { + continue; + } + if ( !$this->copyResolvedPath( + $source . '/' . $item, + $destinationNormalized . '/' . $item, + $update, $force )) { + return false; + } + } + return true; + } + + protected function removeResolvedPath( $path ) + { + $info = $this->inspectPath( $path, true ); + if ( $info === false ) { + return false; + } + if ( $info['type'] === 'link' || $info['type'] === 'file' ) { + return @unlink( $path ); + } + if ( $info['type'] !== 'dir' ) { + return false; + } + + $items = @scandir( $path ); + if ( !is_array( $items )) { + return false; + } + foreach ( $items as $item ) { + if ( $item === '.' || $item === '..' ) { + continue; + } + if ( !$this->removeResolvedPath( $path . '/' . $item )) { + return false; + } + } + return @rmdir( $path ); + } + + protected function probeAfsIdentity( $path, $nofollow=false, $fresh=false ) + { + $cacheKey = ( $nofollow ? 'nofollow:' : 'follow:' ) . $path; + $arguments = array( 'getfid', '-path', $path ); + if ( $nofollow ) { + $arguments[] = '-nofollow'; + } + $output = $this->runFs( $arguments ); + if ( $output === false || $this->lastFsStatus !== 0 + || !preg_match( '/\(([0-9]+\.[0-9]+\.[0-9]+)\) contained in volume ([0-9]+)\s*$/', + $output, $matches )) { + return false; + } + + $identity = array( + 'fid' => $matches[1], + 'volume' => $matches[2] + ); + $this->identityCache[$cacheKey] = $identity; + return $identity; + } + + protected function probeAfsVolumeMountPoint( $path ) + { + $output = $this->runFs( array( 'lsmount', '-dir', $path )); + if ( $output !== false && $this->lastFsStatus === 0 + && preg_match( "/ is a mount point for volume '([^']+)'\\s*$/", + $output, $matches )) { + $this->volumeMountCache[$path] = $matches[1]; + return $matches[1]; + } + if ( $output !== false && $this->lastFsStatus !== 0 + && preg_match( '/ is not a mount point\.\s*$/', $output )) { + $this->volumeMountCache[$path] = false; + return false; + } + + $this->volumeMountCache[$path] = null; + return null; + } + + protected function loadKernelMountPoints() + { + $lines = @file( '/proc/self/mountinfo', FILE_IGNORE_NEW_LINES ); + if ( !is_array( $lines )) { + return false; + } + + $mounts = array(); + foreach ( $lines as $line ) { + $fields = preg_split( '/\s+/', $line ); + if ( !isset( $fields[4] )) { + return false; + } + $path = str_replace( + array( '\\040', '\\011', '\\012', '\\134' ), + array( ' ', "\t", "\n", '\\' ), + $fields[4] ); + $path = $this->normalizeAbsolutePath( $path ); + if ( $path !== false ) { + $mounts[] = $path; + } + } + return array_values( array_unique( $mounts )); + } + + protected function isKernelMountPoint( $path ) + { + return is_array( $this->kernelMountPoints ) + && isset( $this->kernelMountPoints[$path] ); + } + + protected function normalizeAbsolutePath( $path ) + { + if ( !is_string( $path ) || $path === '' + || strpos( $path, "\0" ) !== false ) { + return false; + } + $path = str_replace( '\\', '/', $path ); + if ( substr( $path, 0, 1 ) !== '/' ) { + return false; + } + + $clean = array(); + foreach ( explode( '/', $path ) as $segment ) { + if ( $segment === '' ) { + continue; + } + if ( $segment === '.' || $segment === '..' ) { + return false; + } + $clean[] = $segment; + } + return '/' . implode( '/', $clean ); + } + + protected function validLeafName( $name ) + { + return is_string( $name ) && $name !== '' && $name !== '.' + && $name !== '..' && strpos( $name, '/' ) === false + && strpos( $name, "\0" ) === false; + } + + protected function pathWithinRoot( $path ) + { + return $this->dataRoot !== '' + && ( $path === $this->dataRoot + || strpos( $path, $this->dataRoot . '/' ) === 0 ); + } + + protected function statIsLink( $stat ) + { + return is_array( $stat ) && isset( $stat['mode'] ) + && ( $stat['mode'] & 0170000 ) === 0120000; + } + + protected function statIsDirectory( $stat ) + { + return is_array( $stat ) && isset( $stat['mode'] ) + && ( $stat['mode'] & 0170000 ) === 0040000; + } + + protected function statIsFile( $stat ) + { + return is_array( $stat ) && isset( $stat['mode'] ) + && ( $stat['mode'] & 0170000 ) === 0100000; + } + + protected function pathLstat( $path ) + { + clearstatcache( true, $path ); + return @lstat( $path ); + } + + protected function pathStat( $path ) + { + clearstatcache( true, $path ); + return @stat( $path ); + } + + protected function pathRealpath( $path ) + { + clearstatcache( true, $path ); + return @realpath( $path ); + } +} diff --git a/afs_contract.php b/afs_contract.php new file mode 100644 index 00000000..204bcbf3 --- /dev/null +++ b/afs_contract.php @@ -0,0 +1,47 @@ +`. +2. Accepts only output matching the exact English form + `Callers access to ... is `. +3. Lowercases and returns the rights string. +4. Maps `l`, `a`, `d`, `i`, `r`, and `w` to public privilege flags, but only + enters the mapping block if lookup (`l`) is present. + +The implementation is at `194b4d0:afs.php:651-685`. + +For every displayed item, Tiny File Manager constructs `Afs`, whose constructor +already invokes `getACLAccess()`, and then explicitly calls `getACLAccess()` a +second time (`194b4d0:tinyfilemanager.php:2015-2017,2072-2074`). Therefore a +normal directory listing performs two shell commands per displayed entry. + +The resulting privilege flags do not authorize or hide actions. The delete, +rename, copy, edit, upload, and download controls continue to be governed by +`FM_READONLY`, not AFS rights. `Afs::get_js_declarations()` can expose the flags +to JavaScript, but Tiny File Manager never calls it +(`194b4d0:afs.php:933-952`). + +### ACL reading + +`Afs::readAcl($path)` runs `/usr/bin/fs listacl ` and parses normal and +negative ACL entries into boolean maps for these AFS rights: + +| Right | Meaning used by the UI | +| --- | --- | +| `l` | lookup | +| `r` | read | +| `w` | write | +| `i` | insert | +| `d` | delete | +| `k` | lock | +| `a` | administer | + +The parser is at `194b4d0:afs.php:593-649`. It depends on the exact English +headings emitted by `fs listacl`, splits on `Negative rights:`, and recognizes +an error only if output starts with `fs:`. It does not force a stable locale. + +The AFS permissions page calls this method and renders the currently returned +normal and negative entries (`194b4d0:tinyfilemanager.php:1865-1937`). It does +not provide a control to add a new principal. + +Known UI defects in the old tip include: + +- Both normal and negative lock checkboxes test `$perms['l']` instead of + `$perms['k']` (`194b4d0:tinyfilemanager.php:1919,1934`). +- ACL principal names and the full path are not consistently escaped in the + generated HTML. +- The page is offered for regular files as well as directories. Actual + `fs setacl` behavior for regular-file paths remains a live-AFS validation + requirement. + +### ACL writing + +`Afs::changeAcl()` supports normal or negative ACLs and optionally recursive +changes. It shell-quotes the entity, rights, and path, then executes either: + +```text +/usr/bin/fs sa [ -negative] +``` + +or an unqualified `find ... -type d -exec /usr/bin/fs sa ...` command for +recursive operation (`194b4d0:afs.php:563-591`). It treats any output containing +`fs:` as failure. + +Tiny File Manager disables the POSIX chmod GET and POST flows while AFS support +is enabled and substitutes ACL flows +(`194b4d0:tinyfilemanager.php:1025-1112,1793-1863`). The AFS POST handler: + +- handles only `$_POST['normal']`; +- removes a synthetic `acl` field, concatenates checked right names, and sends + `none` when a principal has no checked rights; +- constructs a new `Afs` object for every normal principal; +- short-circuits after the first failed update, so earlier ACL changes can + remain applied while later principals are skipped; and +- leaves its redirect commented out. + +Although the GET page renders negative rights, the POST handler ignores +`$_POST['negative']`. The UI also exposes neither recursive changes nor the +latent `negative=true` argument. These are incomplete features, not merely +untested ones. + +`readAcl()`, `changeAcl()`, and `getACLAccess()` accept arbitrary path arguments +and do not independently call `pathSecurity()` or `makePathAFSlocal()`. An +`Afs` constructor that rejects its path still produces an object, and Tiny File +Manager then calls `getACLAccess()` on the original path. Consequently, enabling +AFS does not prevent ACL commands from being attempted on a non-AFS Tiny File +Manager root. + +## Latent AFS helpers + +The following behavior exists in `afs.php`, but it is not connected to Tiny +File Manager's forms or action handlers. + +### Path and mount confinement primitives + +`Afs` uses the device number returned by `stat('/afs/')` as its definition of +“in AFS.” + +- `pathSecurity($path)` follows the path with `stat()`, accepts it only when its + device matches `/afs`, and strips a trailing slash. Its own comment calls it + a raceable initial check (`194b4d0:afs.php:804-830`). +- `makePathAFSlocal($path)` changes into a directory, stats `.`, and requires + the same AFS device before later code operates on basenames + (`194b4d0:afs.php:833-849`). +- `linkSafeFileExists($path)` uses `lstat()`, so a broken symlink counts as an + existing entry (`194b4d0:afs.php:860-869`). +- `setPath()` treats literal `/afs` and `/afs/` as null, then attempts to run + `pathSecurity()` on the null path. The AFS root therefore cannot be + represented correctly (`194b4d0:afs.php:872-896`). + +`Afs::__construct()` attempts to detect a missing AFS mount by rejecting the +case where `/afs` and `/` have the same device number +(`194b4d0:afs.php:56-68`). Failure of `stat('/afs/')` itself is not checked +before array access. This heuristic also assumes that all paths intended to be +managed share `/afs`'s device number. + +There is no explicit OpenAFS/AuriStor volume-mount-point API. Real nested mounts +with another device are rejected by the helper checks. Whether OpenAFS and +AuriStor volume mount points present the expected `stat()` and `is_link()` +semantics must be established on live mounts. + +### Legacy dispatcher + +The constructor creates a session `formKey` and calls `processCommand()`. That +dispatcher requires `$_POST['command']` plus a matching form key and routes +these legacy commands: + +| Command | Helper | +| --- | --- | +| `newfolder` | `Afs::createFolder()` | +| `rename` | `Afs::afsRename()` | +| `cut` | `Afs::moveFiles()` | +| `copy` | `Afs::copyFiles()` | +| `delete` | `Afs::deleteFiles()` | + +See `194b4d0:afs.php:84-93,126-156`. No Tiny File Manager form or handler emits +`command`, `formKey`, `selectedItems`, `originPath`, or `newName`; those field +names occur only in `afs.php`. The dispatcher is therefore unreachable from the +Tiny File Manager UI as committed. + +### Latent operation behavior + +| Helper | Intended AFS behavior | Important limitations | +| --- | --- | --- | +| `Afs::createFolder()` (`afs.php:197-220`) | Changes into a verified AFS directory, reduces the requested value to a basename, uses `lstat()` collision detection, and creates the directory. | Uses mode `0644`, which lacks directory execute/search bits. | +| `Afs::removeFolder()` / `deleteFiles()` (`afs.php:223-324`) | Recursively checks directory devices, avoids descending through symlinked directories, and unlinks entries relative to a checked working directory. | Some unlink results are ignored; no Tiny delete flow calls these methods. | +| `Afs::afsRename()` (`afs.php:327-362`) | Rejects symlink rename, checks destination with `lstat()`, and delegates to a root-confined rename helper. | Calls undefined `filedrawers_rename()`. The `filedrawers` extension check is commented out at `afs.php:18-24`, and no implementation exists in the repository. | +| `Afs::moveFiles()` (`afs.php:364-390`) | Delegates moves to `filedrawers_rename(source,destination,'/afs')`. | Also depends on the missing function and is unreachable from Tiny forms. | +| `Afs::copyFiles()` / `copy_dirs()` (`afs.php:392-475`) | Recursively checks source and destination directories against the AFS device and dispatches files and links to `Afs::copy()`. | It creates the target directory before its self-copy equality check, can leave a directory after failure, and does not explicitly preserve ACLs. | +| `Afs::copy()` (`afs.php:478-541`) | For regular files, opens the source, verifies the opened handle's device, verifies the destination parent, opens the destination with exclusive `xb`, and copies in 1 MiB chunks. For symlinks, it reproduces the link without dereferencing it after checking both parent directories. | It preserves links whose target may resolve outside AFS; uses the source basename instead of the requested destination basename for link creation; does not check read/write results; and can report success after a short write. | +| `Afs::readfile()` (`afs.php:544-561`) | Opens the configured path, verifies the opened handle's device, and only then streams it to the client. | No Tiny download, view, or direct-link path calls it. | + +Other latent helpers include symlink-aware type detection, JavaScript folder +listing, a breadcrumb rooted at `/afs`, Smarty assignments, and privilege/entry +JavaScript declarations (`194b4d0:afs.php:97-123,687-802,898-963`). They are +legacy FileDrawers-style code rather than Tiny File Manager integration. +`getType()` references an absent `Mime` class, `get_foldercontents_js()` uses a +commented-out MIME icon assignment, and `originPath` is created as an undeclared +dynamic property. + +## Generic Tiny File Manager data-plane paths + +The table below describes the operations actually reached from Tiny File +Manager. None of them invokes the corresponding AFS-safe helper. + +| Operation | Active Tiny File Manager path at `194b4d0` | AFS and symlink coverage | +| --- | --- | --- | +| Root and navigation | `FM_ROOT_PATH` remains the configured document root; `FM_PATH` is lexically cleaned (`tinyfilemanager.php:347-386,2449-2459`). | No requirement that the root or resolved path be on the `/afs` device. Lexical cleanup does not establish a resolved-path device boundary. | +| Create file/folder | Ordinary `fopen()` and `fm_mkdir()` (`tinyfilemanager.php:600-633,2353-2364`). | No AFS device validation. `fm_mkdir()` creates recursively with mode `0777` subject to umask. | +| Copy/duplicate | `fm_rcopy()` and `fm_copy()` (`tinyfilemanager.php:635-704,2323-2387`). | Uses `is_dir()`, `scandir()`, and PHP `copy()`. Because `is_dir()` is tested before `is_link()`, a symlink to a directory is followed and can traverse outside AFS. | +| Move/rename | `fm_rename()` and PHP `rename()` (`tinyfilemanager.php:635-765,767-793,2306-2313`). | No device check and no AFS-specific link handling. Unlike latent `afsRename()`, symlinks are not categorically rejected. | +| Delete | Single and mass handlers call `fm_rdelete()` (`tinyfilemanager.php:578-598,890-918,2229-2250`). | `fm_rdelete()` checks `is_link()` first and unlinks the link instead of recursing through it, but applies no AFS device boundary to real directories or nested mounts. | +| Browser upload | Uses `$_REQUEST['fullpath']`, recursive `mkdir(0777)`, and `move_uploaded_file()` (`tinyfilemanager.php:823-888`). | No AFS resolved-path or device validation. No helper confines the requested full path to AFS. | +| URL upload | Downloads to `sys_get_temp_dir()` and then calls ordinary `rename()` into the destination (`tinyfilemanager.php:495-573`). | No AFS destination check. Moving a local temporary file into AFS is cross-filesystem-sensitive. The proxy commit affects only the HTTP fetch, not destination safety. | +| Download | Uses `is_file()`, `filesize()`, and built-in `readfile()` (`tinyfilemanager.php:795-821`). | Does not use opened-handle device verification and follows file symlinks. | +| View/quick view | Uses MIME/file inspection and `file_get_contents()` for text; media URLs point at the web-visible file URL (`tinyfilemanager.php:1502-1696`). | No AFS helper or device check. File symlinks are followed. Media and “Open” links move the read into the web server. | +| Edit/save/backup | Editors use `fopen(...,'w')`; AJAX save and backup use ordinary `fopen()`/`copy()` (`tinyfilemanager.php:398-445,1698-1789`). | No AFS device or opened-handle confinement. Symlink targets can be read or modified through normal PHP resolution. | +| Archive creation | Changes into the current path and calls `FM_Zipper` or `FM_Zipper_Tar` (`tinyfilemanager.php:920-967,3011-3185`). | Recursive archive creation uses `is_dir()`/`scandir()` without an AFS boundary and can follow symlinked directories. | +| Archive extraction | Calls `ZipArchive::extractTo()` or `PharData::extractTo()` (`tinyfilemanager.php:969-1023,3056-3067`). | The fork adds no AFS device validation or archive-entry confinement before extraction. | +| Direct link | Folder/file rows and the viewer emit `FM_ROOT_URL` links (`tinyfilemanager.php:1608-1609,2055,2133`). | Bypasses PHP and `Afs::readfile()` entirely. Authentication, token use, symlink resolution, and confinement become web-server concerns. | + +Search, image preview, MIME probing, directory-size calculation, and other +ordinary Tiny File Manager reads likewise remain outside `Afs`. + +## Mount-point and symlink behavior summary + +### What the latent helpers attempt + +- Define AFS membership as equality with `/afs`'s `st_dev`. +- Reject `/afs` when it appears to be part of `/` rather than a separate mount. +- Change into a verified parent and operate on basenames to reduce path races. +- Verify opened regular-file handles before copy or download. +- Use `lstat()` for destination collision checks. +- Unlink symlinks during deletion rather than walking through them. +- Preserve symlinks during copy rather than copying their targets. + +### What the wired application actually does + +- Does not require `FM_ROOT_PATH` to resolve inside AFS. +- Does not use device checks for create, copy, move, delete, upload, download, + edit, archive, view, or direct-link paths. +- Follows directory symlinks during generic copy and archive creation. +- Follows file symlinks during generic download, view, and edit. +- Correctly unlinks a symlink rather than recursively deleting its target in + the generic delete helper, but does not stop at real filesystem boundaries. +- Has no explicit handling for OpenAFS/AuriStor volume mount points. + +AFS volume mount points, ordinary links within AFS, links out of AFS, broken +links, and any Unix mounts below the configured root require separate live +coverage. A test of one kind does not establish the behavior of the others. + +## Other fork-local behavior + +### Proxy commit `da98b2a` + +The first fork-local commit adds a commented `$proxyServer = 'host:port'` +setting (`194b4d0:tinyfilemanager.php:129-132`). In the non-cURL URL-upload +branch it creates an HTTP stream context with: + +```php +array('http' => array( + 'proxy' => 'tcp://' . $proxyServer, + 'request_fulluri' => true, +)); +``` + +See `194b4d0:tinyfilemanager.php:541-547`. The code hardcodes +`$use_curl = false` at line 496, so the proxy is effective for the committed +path. If a later upstream path selects cURL, this setting does not configure a +cURL proxy. + +### Non-AFS changes bundled into `194b4d0` + +These changes are independent of ACL or AFS confinement and should be resolved +deliberately rather than treated as incidental conflict noise: + +| Change | Old-tip evidence | +| --- | --- | +| Disable Tiny File Manager's built-in authentication | `tinyfilemanager.php:20-30` | +| Change date display from `d.m.y H:i` to `m/d/Y H:i:s` | `tinyfilemanager.php:70-72` | +| Disable the online office-document viewer | `tinyfilemanager.php:91-96` | +| Define `FM_EXCLUDE_ITEMS` only when the exclusion list is nonempty, and make an undefined constant mean allow-all | `tinyfilemanager.php:360-366,2480-2493` | +| Update the DataTables CDN from 1.10.20 to 1.10.21 | `tinyfilemanager.php:3692` | +| Add English AFS-right labels, including a duplicate `admin` assignment | `tinyfilemanager.php:4026-4033` | +| Change line endings on the first three lines | opening hunk of commit `194b4d0` | + +## Compatibility blockers and claim boundary + +The rebased result must not be described as AFS-compatible without resolving or +explicitly accepting the following points: + +1. **The active data plane bypasses AFS confinement.** Copy, move, delete, + upload, download, edit, archive, view, and direct-link behavior does not use + the `Afs` safety helpers. +2. **No live AFS evidence exists in this inventory.** Device-number assumptions, + ACL command output, volume mount points, symlinks, and actual kernel + enforcement remain unverified. +3. **Caller credentials are external and undocumented.** The code does not + establish an AFS/AuriStor token or PAG, and `REMOTE_USER` does not bind the + PHP process to that user. +4. **The root and utility locations are fixed or unconstrained.** AFS helpers + hardcode `/afs` and `/usr/bin/fs`, while Tiny File Manager's root remains the + document root and is not required to be in AFS. +5. **Mount and link semantics are incomplete.** The helpers rely on `st_dev` and + POSIX link predicates; generic Tiny paths can follow links outside AFS, and + AFS/AuriStor volume mount-point behavior is unknown. +6. **Legacy move and rename cannot run as committed.** They depend on missing + `filedrawers_rename()`. +7. **ACL editing is incomplete.** Negative changes are ignored, lock state is + rendered incorrectly, partial normal-ACL updates are possible, and regular + file ACL behavior has not been established. +8. **ACL command parsing and performance are fragile.** It assumes exact English + output, does not set a stable locale, has limited error detection, and runs + `getcalleraccess` twice per displayed entry. +9. **Some latent helpers have correctness defects.** Literal `/afs` path + handling, directory creation mode, link-copy destination naming, unchecked + stream writes, and self-copy cleanup all need decisions or fixes before use. +10. **Source provenance is incomplete.** The newly added file references a + missing `COPYRIGHT` document. + +At the old fork tip, it is accurate to claim only that Tiny File Manager has an +AFS-aware ACL display/editor and a collection of dormant AFS-oriented helper +methods. It is not accurate to claim that all managed paths are confined to AFS +or that all file operations are AFS-safe. + +## Live-AFS validation targets + +The following require a live OpenAFS/AuriStor environment and are intentionally +separate from mount-free static/regression tests: + +- `/afs` absent, `/afs` accidentally local, and a correctly mounted `/afs`; +- configured root outside AFS, at `/afs`, and below `/afs`; +- traversal across real AFS/AuriStor volume mount points; +- ordinary in-AFS symlinks, links to another AFS volume, links outside AFS, and + broken links for every read and mutation operation; +- `fs getcalleraccess`, `fs listacl`, and `fs setacl` output and exit behavior + under the deployment locale and AuriStor/OpenAFS client version; +- normal, negative, and lock ACL round trips, including failure partway through + a multi-principal update; +- file-versus-directory paths for ACL operations; +- copy, move, rename, delete, browser upload, URL upload, download, edit, + archive, view, and direct-link behavior under restricted AFS rights; +- URL-upload movement from local temporary storage into AFS; and +- confirmation that the web request runs in the intended user's credential + context and that direct web-server reads enforce the same policy. diff --git a/docs/AFS_REBASE_NOTES.md b/docs/AFS_REBASE_NOTES.md new file mode 100644 index 00000000..cc62d6ee --- /dev/null +++ b/docs/AFS_REBASE_NOTES.md @@ -0,0 +1,205 @@ +# AFS rebase notes + +## Scope and provenance + +The AFS-enhanced fork was replayed onto the fetched canonical Tiny File Manager tip without rewriting any remote-tracking ref. Its seven branch-local commits were later recreated to correct author and committer email metadata, then published with an explicitly authorized force-with-lease. Named safety refs retain both earlier histories. The provider/readiness work described below is part of this environment-neutral application branch. Deployment policy and runtime configuration are maintained separately and are not included here. + +```text +origin https://github.com/karlg100/tinyfilemanager.git +upstream https://github.com/prasathmani/tinyfilemanager.git +branch kag/afs-rebase-upstream-20260817 +``` + +| Role | Commit | +| --- | --- | +| Historical merge base | `2f357ee3d524f1085a7ca2707776c0f33ef85835` | +| Historical proxy commit | `da98b2aa88d9ba2df7c2d67578710faec4431c3e` | +| Historical AFS tip | `194b4d034e99e6ad20c99bb31ea512f12a9a916b` | +| Rebase target (`upstream/master`) | `41491439a6b243c55502581e53fad20bc4c6e777` | +| Replayed proxy commit | `9940e0e56b76ec41bf12a639d321d5afe094aa4f` | +| Replayed AFS commit | `8fd26cbf61e26fdc9831a83cfbb5b777f63f21c7` | +| Post-rebase AFS hardening | `7ea1040cd3d7c6c2b12c5949c8f4604bf72a87b0` | +| Independent-review AFS fix | `a3241138bea6f400534bb5a56c0c81944be08001` | +| Pre-existing upstream CSRF fix | `744c8eb07b024e6208f75ec6585da66f0ec8f0a9` | + +The authoritative old-to-new mapping is: + +```text +da98b2aa88d9ba2df7c2d67578710faec4431c3e -> 9940e0e56b76ec41bf12a639d321d5afe094aa4f +194b4d034e99e6ad20c99bb31ea512f12a9a916b -> 8fd26cbf61e26fdc9831a83cfbb5b777f63f21c7 +``` + +All seven branch-local commits were subsequently recreated with author and committer email `karl@grindleyfamily.com`. Names, trees, raw messages, dates, ordering, and parent topology were preserved. The email-only object mapping is: + +```text +a2df5e893041a3e18134299058f7aa74ccda96d9 -> 9940e0e56b76ec41bf12a639d321d5afe094aa4f +ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1 -> 8fd26cbf61e26fdc9831a83cfbb5b777f63f21c7 +be98d299ec262e34bb2b759b7742c3dfc18bd3af -> 7ea1040cd3d7c6c2b12c5949c8f4604bf72a87b0 +53b5501ed3ab55aef70d720b77e6e4ea8c21c339 -> ab4ca69009ecbb5a9bd73225d25f97065bcd60b9 +029ddb12bdd627601e709bd91d9dc5e801624594 -> a3241138bea6f400534bb5a56c0c81944be08001 +6cdef50404babb797965d152e501b4c5500f61a8 -> 744c8eb07b024e6208f75ec6585da66f0ec8f0a9 +f4302bed20e7514fefdc8e6b0d785b5b8c8848a5 -> 502ac7013d4f307b7c3a58a1ecb3a09b9a0d8ddb +``` + +The historical fork tip and the complete pre-email-rewrite branch are retained at: + +```text +refs/heads/safety/afs-pre-rebase-194b4d0-20260817 +refs/heads/safety/afs-pre-email-rewrite-f4302be-20260817 +``` + +Useful provenance checks are: + +```sh +git range-diff --creation-factor=100 \ + 2f357ee3d524f1085a7ca2707776c0f33ef85835..194b4d034e99e6ad20c99bb31ea512f12a9a916b \ + 41491439a6b243c55502581e53fad20bc4c6e777..8fd26cbf61e26fdc9831a83cfbb5b777f63f21c7 + +git diff --exit-code \ + 194b4d034e99e6ad20c99bb31ea512f12a9a916b:afs.php \ + 8fd26cbf61e26fdc9831a83cfbb5b777f63f21c7:afs.php +``` + +The second command is clean: `afs.php` in the replay commit is byte-for-byte the historical file. Any later `afs.php` hardening is intentionally a post-rebase change, not a rewritten historical commit. Because the proxy patch was adapted around substantial upstream changes, `git range-diff` may show it as an old deletion plus a new addition; the explicit mapping above records its provenance. + +## Semantic conflict inventory + +The three-way audit found one proxy configuration conflict and 16 conflict blocks while replaying the AFS integration. The blocks below are numbered so that every resolution is reviewable even where one conceptual decision covered several adjacent hunks. + +### Proxy configuration + +| ID | Conflict | Resolution | +| --- | --- | --- | +| P-01 | The old proxy declaration expected the end of the 2020 configuration section. Upstream inserted editor-language, external-resource, and `config.php` override support there. | Keep all current upstream configuration. Place the optional `$proxyServer` declaration immediately before `config.php` is loaded, allowing a deployment override. In the URL-upload path, retain upstream URL/port SSRF checks and destination handling; only add the proxy-enabled HTTP stream context. | + +The proxy is not a security boundary. A forward proxy performs its own DNS resolution and redirect handling and may be able to reach addresses that the application's hostname check did not anticipate. Its egress policy must therefore be independently restricted and tested. + +### Defaults, configuration, and bootstrap + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-01 | The old AFS commit carried a mixed-CRLF edit to the default JSON and the removed `calc_folder` setting, while upstream now stores `theme`. | Keep the upstream JSON and `theme`; normalize replayed lines to LF. The removed setting is not an AFS feature. | +| AFS-02 | The fork's site-specific date format overlapped upstream's current date format and new `path_display_mode`. | Keep both current upstream settings. Date formatting can still be changed through deployment configuration. | +| AFS-03 | The old import point collided with upstream editor mappings, `config.php`, ACE settings, and external-resource configuration. | Keep the whole upstream bootstrap. Define `$afsSupport = false` before `config.php`, load `config.php`, and then conditionally load `__DIR__ . '/afs.php'`. This makes AFS an explicit deployment opt-in and avoids breaking ordinary non-AFS Tiny File Manager installations. | +| AFS-04 | The fork conditionally omitted `FM_EXCLUDE_ITEMS`; upstream always defines it, serializes it for old PHP, and supports full-path exclusions. | Keep upstream's definition and behavior. Do not restore the fork's undefined-constant fallback. | + +The old commit also changed authentication to disabled and disabled the online viewer. Those are deployment preferences, not AFS semantics. The historical replay deliberately retains upstream's authentication-enabled default, global-readonly behavior, online-viewer default, current theme, and current date format. The later data-plane readiness lane described below separately disables the online viewer whenever AFS mode is active, because it would disclose a protected file URL to a third party. + +### Permission and ACL actions + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-05 | The fork split the POSIX chmod handler before upstream added CSRF verification. | Retain the upstream `token` requirement, `verifyToken()` failure behavior, validation, translated messages, and redirect. Add only `!$afsSupport` to select the POSIX branch. | +| AFS-06 | The old AFS ACL POST handler collided with the end of the upstream action section. It had no current CSRF flow, skipped path cleaning, and did not redirect after mutation. | Add a separate AFS branch with the same readonly, platform, CSRF, path-cleaning, existence, message, and post/redirect/get behavior as upstream. The replay preserves the historical normal-ACL update behavior; negative-ACL mutation is a documented follow-up requirement. | +| AFS-07 | The old AFS ACL form collided with the current POSIX form and main-view boundary. | Keep the current POSIX view for non-AFS mode. Add a Bootstrap-5-aware AFS view using current path-display policy, a hidden CSRF token, guarded ACL arrays, current footer flow, and encoded displayed principals. | + +### File-list metadata and table layout + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-08 | The old table header removed the Owner column with markup from an older Bootstrap/DataTables layout. | Keep the upstream table and suppress only Owner while AFS metadata is displayed. | +| AFS-09 | Folder permission lookup overlapped upstream's raw/sortable modification time and hardened POSIX owner/group lookup. | Preserve current sorting and POSIX error handling. In AFS mode only, display `fs getcalleraccess` output in the permissions column. | +| AFS-10 | The folder Owner cell conflicted independently with the new folder-row markup. | Retain current folder actions and hide only the Owner cell in AFS mode. | +| AFS-11 | File permission and owner lookup conflicted with current size/date sorting, preview, and owner fallback changes. | Preserve all current file-row behavior. Substitute AFS access text for POSIX mode bits and hide only Owner in AFS mode. | +| AFS-12 | The empty-table colspan was hard-coded for the old column set. | Compute visible metadata and content column counts once and keep the translated upstream empty-folder label. | +| AFS-13 | The summary-footer colspan and old badges no longer matched upstream's readonly and Bootstrap-5 layouts. | Use the computed total column count while retaining current summary text and badges. | + +The parent-directory row was adjusted with the same Owner-column rule; otherwise it would have remained misaligned even if the header and ordinary rows were correct. + +### Helpers, assets, and translations + +| ID | Conflict | Resolution | +| --- | --- | --- | +| AFS-14 | The old one-argument exclusion helper and undefined-constant guard conflicted with upstream's two-argument full-path exclusion helper. | Keep upstream's helper, call signature, serialization compatibility, and filename, extension, and full-path checks unchanged. | +| AFS-15 | A small historical DataTables version edit expanded into a wide footer/JavaScript conflict after upstream's Bootstrap, resource, CSP, and editor refactors. | Keep the complete upstream footer and configurable resource map. Do not downgrade or hard-code DataTables. The later AFS production gate requires a complete reviewed local-resource override and a deployment CSP rather than permitting the replay's public CDN defaults. | +| AFS-16 | The old inline AFS translations collided with the relocated and expanded `lng()` function and ACE footer code. | Keep the current footer/ACE code. Add each AFS English fallback key once in the current `lng()` table; do not restore the duplicate `admin` entry or replace `translation.json`. | + +## Upstream security and behavior deliberately preserved + +The replay does not intentionally remove or bypass these post-2020 upstream changes: + +- authentication enabled by default, global readonly, per-user roots, and current session handling; +- CSRF tokens on the upstream-protected mutation routes, including both POSIX and AFS permission changes; the pre-existing single-copy GET mutation is corrected separately after the replay; +- URL-upload localhost/loopback and known-port rejection before the optional proxy context; +- current path cleaning, archive-item cleaning, filename validation, and NUL-byte rejection; +- current filename and path output encoding and excluded-name, extension, and full-path checks; +- current file download token/session behavior, upload naming, and error handling; +- current Bootstrap, external-resource, translation, theme, editor, sorting, and responsive-table behavior. + +Preserving these controls is not a claim that upstream has complete symlink-safe confinement. The AFS-specific gaps below remain material. + +## Compatibility blockers at the replay boundary + +Do not claim general AFS/AuriStor compatibility from `8fd26cb` alone. + +1. Tiny File Manager calls only `Afs::changeAcl()`, `Afs::readAcl()`, and `Afs::getACLAccess()`. Its forms do not use the legacy `command`, `formKey`, `selectedItems`, or `originPath` protocol. The AFS-safe copy, recursive copy/delete, move, and read helpers in `afs.php` are therefore dormant. +2. Save, backup, create, copy/duplicate, move/rename, delete, upload/chunked upload/URL upload, download, view, direct links, and archive paths still use generic upstream I/O. A lexical `FM_PATH` check does not stop an in-root symlink from reaching an AFS path outside `FM_ROOT_PATH` or a local filesystem path. Direct links bypass PHP entirely. +3. `Afs::pathSecurity()` in the historical file compares device IDs with `/afs`; it does not prove containment below `FM_ROOT_PATH`. A same-device AFS path outside the configured root can pass, a local filesystem mounted at `/afs` can be misidentified, and an alternate AFS mount root is unsupported. +4. The replayed `afs.php` assumes a readable `/afs`, `/usr/bin/fs`, enabled `shell_exec`, the POSIX extension, `REMOTE_USER`, and exact English `fs listacl` and `fs getcalleraccess` output. It does not robustly handle all failures. +5. Dormant paths refer to `filedrawers_rename` and `Mime`, neither of which is supplied by this repository. The undeclared `originPath` property also causes modern-PHP compatibility concerns. +6. The replayed UI displays negative ACLs but the POST handler updates normal ACLs only. Both lock checkboxes read the `l` state instead of `k`, so their initial state can be wrong. A negative entry with all boxes cleared is not posted. +7. The historical constructor performs request processing and `getcalleraccess`, after which the listing calls `getcalleraccess` again. This produces two subprocesses per listed item and gives construction unexpected side effects. +8. OpenAFS ACLs are directory-oriented. The UI offers permission editing for files as well as directories; exact OpenAFS and AuriStor behavior must be established on the target client and server versions. +9. A proxy can change URL-upload name resolution and reachable networks. The retained upstream hostname check is not a substitute for proxy-side egress policy. + +## Post-rebase fixes and automated tests + +The two mapped commits above are the historical replay layer and should remain unchanged. Hardening and tests belong in one or more commits after `8fd26cb` so `git range-diff` continues to show what was replayed versus what was newly repaired. + +Commit `7ea1040cd3d7c6c2b12c5949c8f4604bf72a87b0` implements the separately reviewable production hardening: + +- fail-closed AFS availability, path, stat, command-execution, and parser handling; +- removal of constructor request/shell side effects and exactly one explicit access lookup per listed item; +- declared runtime state, all seven standard caller-access flags including `k`, and testable ACL parsing; +- case-preserving round trips for standard `lrwidka` and AuriStor auxiliary `A-H` rights, so uppercase `A`/`D` cannot be confused with lowercase admin/delete; +- negative ACL updates, correct `k` checkbox state, clearing ACL entries, and one `fs` invocation per positive or negative ACL set; +- inherited AuriStor ACL detection with read-only UI and server-side mutation rejection, avoiding accidental materialization of an inherited file ACL; +- strict ACL-output validation that rejects unknown rights, duplicate rights, missing headers, and malformed command output; +- safe missing-`filedrawers_rename` behavior, complete copy writes including flush/close failures, partial-destination cleanup, recursive-copy ancestry rejection, and correct symlink destination names; +- a test-overridable AFS root and command runner while retaining the production defaults `/afs` and `/usr/bin/fs`; +- explicit retention of the narrower ACL display/edit claim because generic data-plane routing was not changed. + +The case and inheritance handling follows the AuriStor [`fs listacl`](https://www.auristor.com/documentation/man/linux/1/fs_listacl.html) and [`fs setacl`](https://www.auristor.com/documentation/man/linux/1/fs_setacl.html) contracts: auxiliary rights are uppercase `A-H`, inherited file ACLs are marked in list output, and setting an ACL on such a file creates a file-specific ACL. + +Independent review produced two additional, separately reviewable fixes after the original hardening commit: + +- Commit `a3241138bea6f400534bb5a56c0c81944be08001` makes `Afs::copyFiles()` and recursive `copy_dirs()` share a link-first dispatcher. Directory symlinks and broken links are reproduced as links, direct `copy_dirs()` calls reject a symlink source, and FIFO or other unsupported file types fail without creating a destination. These helpers remain dormant from Tiny File Manager's active data plane and retain same-device and check/use limitations. +- The same commit makes the ACL parser explicitly recognize the AuriStor `Volume access list for ... is` boundary and fail closed instead of exposing any following MaxACL entries as editable object ACL entries. Until a separate read-only MaxACL model is implemented and validated live, ACL editing is disabled on volumes whose `fs listacl` output includes this block. + +The reported nested-key principal mangling was retracted after PHP 7.4 and 8.3 reproduced dotted and spaced principals intact. A regression fixture records that behavior; the keyed ACL form was not changed without a failing case. + +Commit `744c8eb07b024e6208f75ec6585da66f0ec8f0a9` converts single copy, move, and duplicate completion from a state-changing GET link to a token-verified POST form. This was a pre-existing canonical-upstream issue relevant to ambient SSO, not a conflict or regression introduced by the AFS replay. + +The no-live-mount regression layer is intentionally separate as well: + +- `tests/afs_regression.php` exercises ACL parsing and command construction, MaxACL fail-closed behavior, case-sensitive auxiliary rights, inherited ACLs, dotted/spaced principal keys, caller-access flags, path/device rejection, handle-time copy/read checks, directory and broken symlinks, unsupported special files, and helper inventory without touching `/afs`. The follow-on data-plane lane adds an offline path-policy model for rooted reads/writes, uploads, recursive operations, POSIX links, modeled kernel mounts, and modeled AFS volume boundaries; that model is not a production descriptor-boundary test. +- `tests/afs_static.php` checks default-off/config ordering, conditional implementation loading, the pre-config side-effect-free provider contract, retained upstream CSRF/URL-upload/exclusion controls, token-verified POST completion for single copy/move/duplicate, normal and negative ACL handling, all 15 rights, inherited-ACL gates, `k` mapping, strict provider status checks, and provider-owned metadata/ACL calls. +- `tests/afs_io_path_audit.php` is the current route inventory. It classifies all original 18 data-plane routes plus navigation, search, ACLs, MIME/metadata, raw protected URLs, and readiness. It deliberately has no `PROTECTED` result until a descriptor-backed provider and live evidence exist. +- `tests/afs_readiness.php` executes the immutable profile, provider/factory identity binding, exact CSP, settings/direct/raw/embed/URL-upload gates, and canonical JSON asset-manifest checks. It covers missing/extra rows and fields, licenses, required `defer`, lowercase SHA-256, traversal, symlinks, missing files, and manifest-file confinement. These checks do not establish web-server authentication provenance, CSP behavior in a browser, transitive assets, HTTP delivery, or a production descriptor boundary. +- Run PHP lint on `tinyfilemanager.php`, `afs.php`, and every PHP test, followed by every focused suite and any available upstream checks. + +The finalized application lane is linted and executed under both PHP 7.4 and PHP 8.3. It includes 137 regression assertions, 564 static integration assertions, 306 readiness assertions, and a 24-classification/165-assertion I/O audit. The route result is 18 `TRANSITIONAL`, 5 `GUARDED-DISABLED`, 1 `LIVE-YFS`, 0 `PROTECTED`, 0 `XFAIL`, and zero failures. A green result proves the stated dispatch and fail-closed contracts only; it is not deployability evidence. + +Static tests can validate dispatch, parsing, escaping, and fail-closed behavior, but they cannot validate PAG/token inheritance, AFS kernel behavior, ACL enforcement, mount points, volume boundaries, or the deployed `fs` output. Those claims require the disposable live plan in `docs/LIVE_AFS_TEST_PLAN.md`. + +### Data-plane readiness follow-on lane + +The follow-on data-plane lane changes the route architecture after the historical commits above. It introduces side-effect-free `AfsDataPlaneProviderFactory` and `AfsDataPlaneProvider` contracts and routes listing/search, create, save/backup, copy/duplicate, move/rename, delete, browser uploads, view/text reads, download, metadata/MIME, and ACL actions through provider-aware entry points. AFS direct-link controls are disabled; ordinary authenticated navigation, view, and download remain available through the controller. Image-hover and image/audio/video raw previews are suppressed, Google/Microsoft online viewing is forced off, settings and password-hash utilities are rejected, URL-upload egress is rejected before URL parsing or network setup, and archive create/extract is rejected before the generic archive classes run. The URL-upload switch remains literal `true` by default outside AFS to preserve upstream behavior and provides an explicit non-AFS opt-out. + +These are transitional guards, not production passes. The bundled `AfsDataPlane` is deliberately pathname-based and returns `false` from `isProductionReady()`. No descriptor-backed provider is supplied by this repository. Production startup requires an exact factory class/build identity, exact provider class/build identity, provider-reported credential identity equal to the single post-config `REMOTE_USER` snapshot, literal `true` readiness and initialization, and the `descriptor-beneath-v1` boundary token. Active AFS routes no longer intentionally fall back to generic managed-root I/O, but the contract still exchanges path strings and the bundled preview still separates path checks from later operations. A provider that merely self-reports readiness cannot close `openat2`/descriptor ownership, TOCTOU, atomic replacement, partial-write, mount classification, or recursive-tree race concerns; those require an independently reviewed native provider or broker and live evidence. + +Within the original 18-route inventory, 13 categories are `TRANSITIONAL`, URL upload, direct links, and archive create/extract are 4 `GUARDED-DISABLED`, and AFS volume-mount semantics are 1 `LIVE-YFS`. Adding the six explicit navigation/search/ACL/MIME/raw-URL/readiness surfaces produces the current total of **18 transitional, 5 guarded-disabled, and 1 live-YFS**. None is a compatibility pass. `GUARDED-DISABLED` means both UI and crafted requests are rejected before the generic implementation; `TRANSITIONAL` means the route reaches the provider seam but lacks native-boundary/live proof. + +AFS startup also requires the exact application-owned 13-directive CSP in `AfsProductionReadiness::LOCAL_ONLY_CONTENT_SECURITY_POLICY`, literal `$content_security_policy_approved === true`, and one canonical version-1 JSON asset manifest. The policy is self-only/none except reviewed `data:` images; it rejects remote, wildcard, `unsafe-inline`, `unsafe-eval`, blob-script/worker, duplicate, missing, extra, and line-breaking forms. PHP emits the sole CSP header; the container must verify it rather than add another. Because the 2.6 templates still contain inline scripts/styles and event attributes, `applicationTemplatesSupportStrictCsp()` deliberately returns false and AFS startup remains unavailable until a nonce/hash/external-template refactor is reviewed. + +The canonical asset schema is `docs/AFS_ASSET_MANIFEST.schema.json`. The same JSON file must be consumed by the application and container lock; a separate PHP or container-only manifest is not accepted. Its exact raw bytes are pinned by a lowercase profile SHA-256 and hashed before parsing. It contains version 1 and exactly ten logical keys. Every row requires `type`, relative no-symlink `path`, lowercase SHA-256, reviewed SPDX `license`, and boolean `defer` (`false` for styles). The application generates tags only after verifying the manifest and each file. Only root-owned, immutable, container-pinned bytes make those hashes a trust anchor. Transitive CSS/font/ACE dependencies, browser URL mapping, MIME, and served bytes remain exact-image checks. The optional favicon has an independent lowercase SHA-256 check. + +An immutable `AFS_PRODUCTION_PROFILE` value `afs-descriptor-v1` ties these gates together. AFS cannot start without it, and a partial profile cannot enable production: AFS and external-auth flags must be literal true; Tiny File Manager local auth must be false; local, readonly, and per-user account maps must be empty; settings, embed, direct-link, raw-preview, and URL-upload surfaces must be false; one normalized nonempty root below `/afs` must bind the post-config snapshot, `FM_ROOT_PATH`, factory, and provider initialization; `FM_ROOT_URL` must remain empty; `FM_SELF_URL` must be root-relative; the manifest digest must be lowercase and exact; and factory/provider identities must match. A pre-defined `FM_ROOT_PATH` with any other value is rejected, as is any pre-defined `FM_URL_UPLOAD_ENABLED` value other than literal `false`. This is structural fail-closed validation, not evidence that Apache actually authenticated the request, established the correct PAG/token, protected headers, or bound that identity before PHP/session/CSRF processing. Those remain exact-container integration blockers. + +## Remaining compatibility blockers + +Before the follow-on data-plane lane, the hardening layer repairs the actively integrated ACL surface and several dormant helpers but leaves all 18 data-plane routes unconfined. The follow-on lane removes the audit's expected failures by routing or disabling them; it does not convert any route to `PROTECTED`. A blanket AFS/AuriStor compatibility claim remains blocked until a real descriptor-backed provider exists, every transition is exercised in the exact deployment, and disabled operations remain unreachable or receive provider-owned implementations. + +Additional live-only blockers are web-worker token/PAG identity, real OpenAFS and AuriStor `fs` output, file-versus-directory ACL semantics, MaxACL display/edit policy, same- and cross-volume behavior, unavailable/read-only mounts, writeback failures, provider handling of `/proc/self/mountinfo` or an equivalent mount inventory, and the browser-visible CSP/resource boundary. Positive and negative ACL sets require separate `fs` commands, so a failure between the two batches can still leave a partial cross-set update; that failure mode must be exercised and documented live. + +The upstream Dockerfile copies only `tinyfilemanager.php`, not `afs_contract.php`, `afs.php`, a production provider, the canonical asset manifest/schema, local assets, or an AFS-aware configuration, so its image is ordinary default-off Tiny File Manager rather than an AFS-capable deployment artifact. `afs_contract.php` is a mandatory reviewed application blob and must be checksum-locked with `tinyfilemanager.php`, `afs.php`, the provider, schema, manifest, and assets. `docs/AFS_APPLICATION_BLOBS.sha256` records the frozen application-side blob digests for downstream closure. Data-plane and container work should remain commit-separated for review but are security-coupled. The PHP lane owns provider dispatch, CSRF, confinement, and raw/direct URL removal. The container lane owns the complete reviewed artifact, authentication bootstrap, local assets, response verification, canonical origin, AFS client and mount namespace, mount inventory, worker UID/PAG/token, trusted-proxy and egress policy, and web-server denial of guessed static paths below the managed root. Neither lane alone closes the other's transitional categories; compatibility requires an exact-image integration run. diff --git a/docs/LIVE_AFS_TEST_PLAN.md b/docs/LIVE_AFS_TEST_PLAN.md new file mode 100644 index 00000000..f27c44ea --- /dev/null +++ b/docs/LIVE_AFS_TEST_PLAN.md @@ -0,0 +1,267 @@ +# Disposable live AFS/AuriStor test plan + +## Objective and claim boundary + +This plan validates the AFS-enhanced Tiny File Manager against a real OpenAFS or AuriStor client. It is the required complement to static tests. A successful static run alone is not evidence that tokens reach the web worker, ACLs are enforced, symlinks are confined, a descriptor boundary survives races, or operations behave correctly across volume mount points. + +The data-plane follow-on lane is deliberately not deployable with its bundled `AfsDataPlane`: that pathname-based class returns `false` from `isProductionReady()`. A live data-plane run may begin only after the candidate includes a separately reviewed descriptor-backed `AfsDataPlaneProvider` (or equivalent native broker) and the application accepts and initializes it. Overriding the readiness boolean without implementing and reviewing the boundary is not a test setup; it is a bypass. Until that prerequisite exists, only the readiness-failure, ACL, and mount-free tests can run, and no AFS data-plane compatibility claim is possible. + +Run this plan only against disposable data and identities. The preferred fixture is a dedicated read-write test volume plus a second disposable volume for cross-volume tests. Never point `FM_ROOT_PATH` at a production volume, user home, shared project tree, or cell root. + +## Safety rules and stop conditions + +- Use an isolated VM or container host with the target AFS/AuriStor client version and a non-production web endpoint bound to loopback or a restricted test network. +- Use dedicated test principals. Do not copy production keytabs, long-lived tokens, cookies, configuration secrets, or ACLs into the evidence bundle. +- Create a unique run ID, a dedicated root, a sibling AFS escape target, and a local-filesystem escape target. Record their exact canonical paths before starting. +- Place a run-ID marker file at each test root. Destructive cleanup is permitted only after the operator verifies the marker, expected volume/FID, and exact path. +- Prefer a disposable volume snapshot/clone before destructive cases. Otherwise create a timestamped archive, file hash manifest, mount-point inventory, and complete ACL baseline. +- Stop immediately if an HTTP operation reads, changes, creates, renames, archives, downloads, or deletes anything outside the dedicated roots; if the web worker has an unexpected identity; or if a mount point resolves to a non-disposable volume. +- Stop if a rendered AFS page emits a raw managed-file URL, contacts an online document viewer, loads an unreviewed remote executable/style/media asset, or permits the web server to serve a guessed managed path without Tiny File Manager authorization. +- Treat a timeout, PHP warning, `fs` parse failure, or unexplained empty ACL as a failure, not as a skipped check. + +Suggested logical names are: + +```text +RUN_ID= +AFS_TEST_ROOT=/afs///tfm- +AFS_CROSS_VOLUME_ROOT=/afs///tfm- +AFS_ESCAPE_ROOT=/afs///outside- +LOCAL_ESCAPE_ROOT=/tmp/tfm-outside- +``` + +Substitute explicit, reviewed paths in commands. Do not use an unset variable, wildcard, cell root, `/afs`, `/`, home directory, or workspace root as a recursive-operation target. + +## Evidence manifest before mutation + +Create a timestamped evidence directory outside all test roots and record: + +- `git rev-parse HEAD`, `git status --short`, the old/new mapping, and the safety-ref object ID; +- container or VM image identity, web-server and PHP versions, loaded PHP extensions, relevant PHP limits, and the exact production-provider artifact and build identity; +- AFS/AuriStor client and `fs` versions, mount configuration, cell name, cache-manager status, and server/volume identity; +- exact test-root, escape-root, mount-point, volume, FID, and canonical-path results; +- sanitized application configuration and checksums of deployed source, provider, local JavaScript/CSS/font/worker assets, and configuration files; +- process UID/GID/groups, SELinux/AppArmor state if applicable, and web service start command; +- token issuer, principal names, PAG identifiers, token expiry times, and `fs getcalleraccess` results, but never token material; +- recursive file inventory with types, sizes, timestamps, hashes for regular files, symlink targets, and ACLs for every directory; +- the response CSP, rendered HTML, browser console, complete browser network trace, canonical external origin, accepted/rejected Host and forwarded-host inputs, and web-server static-location configuration; +- web access/error logs and PHP logs from a clean starting point. + +Keep command output in raw text as well as a short result table. Record the HTTP request, identity, expected result, actual result, and resulting filesystem/ACL delta for every case. + +## Identity, token, and PAG setup + +Use at least these dedicated identities: + +| Identity | Intended access | +| --- | --- | +| ACL administrator | `a` and the rights needed to seed and restore the fixture | +| Editor | lookup/read/write/insert/delete/lock as required by positive tests | +| Reader | lookup/read only | +| Denied principal | a positive grant plus a negative ACL used to prove denial | + +For OpenAFS, create a fresh PAG with the site's approved `pagsh`/Kerberos/`aklog` procedure. For AuriStor, use the site-approved equivalent isolated process credential/token procedure. Record the PAG and sanitized token listing before and after the test. + +The token must belong to the web worker, not merely the interactive shell. Start the disposable web server or PHP-FPM worker from the prepared PAG, or use the deployment's documented credential-injection mechanism. Then prove the effective identity from the same UID, process context, and mount namespace as PHP by comparing: + +1. the sanitized token/PAG view; +2. `fs getcalleraccess` for the test root; +3. a permitted and a denied HTTP filesystem action; and +4. the resulting server-side audit/access log identity. + +Repeat the authorization subset as Editor, Reader, and Denied principal using newly started workers or otherwise isolated credential contexts. Do not reuse a worker that might retain the prior identity. Include an expired/destroyed-token case and verify fail-closed behavior. + +## Deployment under test + +Deploy the exact candidate commit and the separately reviewed production-provider artifact into the disposable web root. `afs_contract.php` is mandatory and must be verified as a reviewed application blob before `config.php` loads the provider. Back up the original test configuration, then set at least: + +```php +define('AFS_PRODUCTION_PROFILE', 'afs-descriptor-v1'); +$afsSupport = true; +$afs_external_auth = true; +$use_auth = false; +$auth_users = array(); +$readonly_users = array(); +$directories_users = array(); +$settings_enabled = false; +$direct_links_enabled = false; +$raw_previews_enabled = false; +$url_upload_enabled = false; +$root_path = '/afs/'; +$root_url = ''; +$online_viewer = false; +$favicon_path = ''; +$external_asset_root = __DIR__ . '/'; +$afs_asset_manifest_file = 'relative/path/to/afs-assets-v1.json'; +$afs_asset_manifest_sha256 = ''; +$content_security_policy = "default-src 'none'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; frame-src 'none'; img-src 'self' data:; media-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; worker-src 'self'"; +$content_security_policy_approved = true; +require_once __DIR__ . '/.php'; +$afsDataPlaneFactory = new ReviewedAfsDataPlaneProviderFactory(); +$afs_expected_factory_class = 'ReviewedAfsDataPlaneProviderFactory'; +$afs_expected_factory_id = 'site.factory:sha256:'; +$afs_expected_provider_class = 'ReviewedAfsDataPlaneProvider'; +$afs_expected_provider_id = 'site.provider:sha256:'; +``` + +Do not use that configuration with the bundled `AfsDataPlane`, a test double, or a provider whose only production change is returning `true` from `isProductionReady()`. The provider must own resolution, metadata, ACLs, and I/O through a descriptor-relative `RESOLVE_BENEATH`/no-magic-link boundary, initially rejecting POSIX symlinks, or an independently reviewed equivalent broker. Exact class/build IDs and provider-reported credential equality prevent accidental substitution but do not prove the implementation, token, or check/use boundary. Review the provider artifact and every call site together. + +The one canonical JSON manifest must validate against `docs/AFS_ASSET_MANIFEST.schema.json` and be the same artifact the container lock consumes. Its exact raw bytes are pinned by `$afs_asset_manifest_sha256` and hashed before JSON parsing. It has version 1 and exactly ten logical asset rows. Each row binds type, relative local path, lowercase SHA-256, reviewed license, and boolean `defer`; style rows require `defer: false`. Do not generate an independent PHP manifest or container-only lock. Only a container-pinned, root-owned, non-writable manifest and asset tree makes these hashes a trust anchor; application validation alone cannot prevent an authorized writer from replacing both config and bytes. Every transitive dependency loaded by CSS, Font Awesome, ACE modes/themes/workers, Dropzone, DataTables, or Highlight.js must also be in the reviewed image/lock. The application validates the top-level files but cannot prove transitive browser loads, URL-to-file mapping, MIME, or served bytes; collect that evidence in the browser and web-server lanes. Keep the favicon empty or bind it to its separate lowercase SHA-256. + +PHP is the only CSP-header source. The exact 13-directive policy above is required and rejects remote, wildcard, unsafe-inline/eval, and noncanonical variants. Do not add a second Apache CSP header. The application still contains inline templates, so `applicationTemplatesSupportStrictCsp()` intentionally makes this configuration return 503 until a reviewed nonce/hash/external-template refactor exists. A live positive data-plane run cannot start before that blocker is resolved; readiness-failure tests must prove the stop remains effective meanwhile. + +AFS mode forces the Google/Microsoft online viewer off, suppresses raw image/audio/video and hover previews, and disables file/folder DirectLink and URL-upload controls. Ordinary controller-mediated navigation, browser upload, view, and download remain. Confirm that a pre-defined non-false `FM_DOC_VIEWER`, enabled settings/direct/raw state, or URL-upload variable/constant other than literal `false` produces the expected 503, then inspect the rendered response and network trace rather than relying on variables alone. A crafted URL-upload request must receive 403 before URL parsing, temporary-file creation, proxy setup, or network access. `FM_ROOT_URL` must be empty and `FM_SELF_URL` root-relative. Configure the web server to deny guessed static URLs below the managed root even though the UI no longer emits them. + +Do not assume the upstream Dockerfile is the candidate deployment: it copies only `tinyfilemanager.php` and omits `afs_contract.php`, `afs.php`, the production provider, manifest/schema, reviewed local assets, and AFS configuration. If a container is used, build or mount an explicitly reviewed AFS-capable artifact and record every component checksum, including the provider contract. The container must expose the intended AFS mount and mount inventory to the worker, supply `/usr/bin/fs` and required PHP extensions, preserve the intended UID/PAG/token, serve the local assets, verify the application-owned CSP, pin the canonical external origin and reject untrusted Host/forwarded-host inputs, sanitize trusted proxy headers, and enforce static-file and URL-upload egress policy. + +Keep the application and container changes reviewable as separate commits, but treat them as one security boundary. The application lane cannot validate the deployed mount, identity, assets, CSP, or static web-server rules. The container lane cannot repair provider dispatch, pathname races, CSRF, or raw-URL generation. Neither lane can claim compatibility until the combined image passes this plan. + +The production profile requires front-end external authentication, disables Tiny File Manager local auth, removes all local/readonly/per-user accounts, and rejects a missing `REMOTE_USER`. That structural check does not prove Apache authenticated before PHP/session/CSRF handling, that the header cannot be spoofed, or that the provider uses the same PAG/token. The exact image must prove those semantics and retain a complete mod_auth/authz/header-trust configuration. Restrict the endpoint by network policy as well. URL upload must stay disabled with proxying unset. A separate non-AFS compatibility test may use a dedicated restricted proxy and record its DNS, redirect, and egress policy; that does not enable the route in AFS production. + +Before using the browser, run PHP lint and every no-live-mount suite against the deployed source. Verify the readiness matrix separately: ordinary upstream mode still works with `$afsSupport = false`; AFS enabled without the immutable profile; profile with AFS disabled; local auth/users; missing external identity; embed/settings/direct/raw/URL-upload enablement; a pre-defined non-false `FM_URL_UPLOAD_ENABLED`; a root outside `/afs`, unnormalized root, or conflicting pre-defined `FM_ROOT_PATH`; raw/absolute URLs; missing contract; bad factory/provider class, build, credential identity, boundary, readiness, or initialization; missing/uppercase/mismatched manifest digest; invalid manifest/assets/hashes/licenses; invalid CSP/approval; and current inline templates each produce a 503 with no file operation. Confirm the exact expected blocker is reported, without PHP warnings or fallback. + +After startup, request login, listing, upload, view, edit, help, and error pages and record for each: + +- the CSP header and any browser CSP violation; +- every script, stylesheet, font, worker, image, media, iframe, favicon, preconnect, and DNS-prefetch request; +- absence of Google/Microsoft viewer requests and raw managed-file URLs; +- denial of guessed static file and directory URLs by the web server; and +- identical reviewed asset checksums in the image and HTTP responses. + +## Fixture layout + +Create a deterministic fixture containing: + +- empty, small text, binary, zero-byte, large, Unicode-name, whitespace-name, dot, and allowed/disallowed-extension files; +- empty and nested directories, a deep tree, and a directory with enough entries to expose per-item command latency; +- same-volume and cross-volume destinations; +- a zip and tar archive with ordinary nested content; +- separately generated traversal archives containing `../`, absolute names, nested symlink entries, and names that collide with existing files; +- relative and absolute symlinks described below; +- a child-volume mount point and, when available, a read-only volume/mount point; +- sentinel files in both escape roots whose content, metadata, hashes, and ACLs are recorded. + +Seed normal and negative ACL entries with `fs` before opening the ACL editor so every row and right can be round-tripped. For AuriStor, include the auxiliary rights `A-H` as distinct case-sensitive rights in addition to standard `lrwidka`. Record `fs listacl`, `fs getcalleraccess`, volume/FID information, and expected effective rights for each identity. + +## ACL and caller-access matrix + +For both a directory and a regular file path, while recording whether the implementation applies an ACL to the file, its parent, or rejects the operation: + +1. Open the ACL editor and compare every normal and negative principal and each standard `l`, `r`, `w`, `i`, `d`, `k`, `a` and AuriStor auxiliary `A-H` checkbox with raw `fs listacl` output. Prove specifically that uppercase `A` is not decoded as lowercase admin `a`, uppercase `D` is not decoded as lowercase delete `d`, and `B`, `C`, `E`, `F`, `G`, and `H` survive unchanged. +2. Toggle each normal right individually, submit with a valid CSRF token, and confirm the exact raw ACL delta and a successful/denied operation that exercises the right. +3. Repeat for negative rights, including adding, changing, and clearing an entry to `none`. Negative ACL controls must not be reported as supported if the POST path ignores them. +4. Verify that the lock checkbox maps `k` to `k`; a principal with `l` but not `k`, and one with `k` but not `l`, must display differently. +5. Submit with a missing token, an invalid token, a stale token, a foreign-session token, and a readonly application account. The ACL must remain byte-for-byte unchanged. +6. Test principal names containing cell qualifiers and characters that exercise HTML/form encoding. Confirm that the displayed principal, submitted key, and `fs` argument are identical and that no markup executes. +7. Remove the token/PAG and repeat read and mutation attempts. Confirm a clear failure without partial ACL changes. +8. Compare the UI permission text with `fs getcalleraccess` under all four identities and across ordinary directories, child mount points, and the cross-volume root. +9. Measure listing time and count `fs getcalleraccess` executions for small and large directories. The expected optimized behavior is no constructor lookup and one explicit lookup per displayed item; record any remaining O(N) usability limit. +10. Capture raw OpenAFS and AuriStor CLI output separately if both implementations are supported. Parser success on one is not evidence for the other. +11. On AuriStor, create a file with an inherited ACL. Verify that the UI identifies it as inherited, disables submission, and that a crafted POST is rejected without converting it to a file-specific ACL. If explicit conversion is tested separately, record the exact command and use the client-supported ACL-removal operation to restore inheritance. +12. Populate multiple positive and negative ACEs and verify one `fs` invocation per set. Force the second set to fail after the first succeeds; record the partial-update behavior and restore the exact baseline before continuing. +13. Apply a disposable Volume Maximum ACL and capture the exact raw `fs listacl` output. The current implementation must report the ACL as unreadable and reject a crafted mutation without changing either the object ACL or MaxACL. A future parser may display MaxACL entries read-only, but must never post them as object ACL entries or calculate effective rights without server evidence. + +A negative ACE alone is not proof of denial when `anonymous` or `system:anyuser` grants the same right. Include authenticated and token-discarded anonymous requests, and treat the observed effective operation—not the checkbox—as the authorization result. + +Restore the ACL baseline after this matrix before starting data-plane tests. + +## Data-plane operation matrix + +The current audit has 24 classifications and no compatibility-pass status: + +- **18 transitional:** provider-wired data, metadata, ACL, navigation/search, and readiness surfaces that still lack the native boundary/live evidence; +- **5 guarded-disabled:** URL upload, DirectLink controls, archive creation, archive extraction, and raw protected URLs/external viewers; +- **1 live-YFS:** AFS volume-mount traversal and mutation semantics; +- **0 protected and 0 XFAIL.** + +Within the original 18 routes, the split is 13 transitional, 4 guarded-disabled, and 1 live-YFS. “Transitional” means a provider-aware call site exists, not that the descriptor boundary or live behavior passed. “Guarded-disabled” means the UI and crafted requests must remain unavailable before any generic code runs. DirectLink and URL upload are disabled; exercise ordinary browser upload/view/download/navigation separately. + +Exercise each transitional row as an allowed Editor, a Reader expected to be denied, and where meaningful the Denied principal. After every request, compare the full test-root and escape-root manifests. For a guarded-disabled row, send both the ordinary UI request (if any control remains) and a crafted request, then prove that the complete manifests are unchanged. + +| State | Area | Cases to execute | Required evidence | +| --- | --- | --- | --- | +| Transitional | Listing/navigation/search | root and nested navigation, parent link, hidden items, exclusions, literal-metacharacter search, large directory, concurrent link-swap attempt | HTTP result, displayed names/access, raw listing, timing, `fs` call count, provider trace, no path-only fallback | +| Transitional | Create | new file, empty file, directory, nested directory, invalid/NUL/path-like name, existing target, concurrent parent/leaf replacement | status/message, type/mode/FID, provider trace, no outside-root delta | +| Transitional | Edit/save/backup | plain fallback and ACE/AJAX save, empty/large content, failed/short write, backup, concurrent replacement | before/after hash and length, CSRF result, descriptor/provider trace, absence of partial data | +| Transitional | Upload | single file, overwrite/collision, zero/large file, disallowed extension, nested folder upload, chunked upload, reordered/retried chunks, interrupted cleanup | request/chunk log, final hash/FID, `.part` cleanup, descriptor/provider trace, destination confinement | +| Guarded-disabled | URL upload | absent tab/form/client function; crafted direct HTTP(S), redirect, loopback, blocked-port, proxy-configured, empty, and malformed requests | 403 before URL parsing/temp creation/cURL/stream/proxy work; no application/proxy/DNS request and no filesystem delta | +| Transitional | View/download | text, binary, zero/large file, valid/invalid/suffix/multiple byte ranges, missing and denied file | status/headers, byte-for-byte hash, token/session behavior, descriptor/provider trace; raw image/audio/video preview remains absent | +| Guarded-disabled | DirectLink/raw URLs | verify no DirectLink control, send crafted/guessed raw static URLs under every identity | no direct action emitted; explicit PHP/profile rejection where applicable; web-server denial and no managed bytes | +| Transitional | Copy/duplicate | file/tree, existing target, same-directory duplicate, direct/deep descendant, large/partial-write case, quota/writeback failure, race, missing/invalid CSRF token | source/destination hashes/types, provider trace, CSRF rejection, error atomicity, partial cleanup, confinement | +| Transitional | Move/rename | file/tree, same volume, cross volume, existing target, race, denied destination, missing/invalid CSRF token | source/destination state, provider trace, CSRF rejection, explicit cross-volume failure or reviewed fallback, no loss | +| Transitional | Delete | file, empty/non-empty tree, single/batch selection, race, symlink, broken link, kernel mount, AFS volume mount point | exact removed objects, provider preflight/trace, sentinel preservation, no traversal into link or mounted volume | +| Guarded-disabled | Archive create | ordinary and crafted ZIP/TAR requests over files, trees, symlinks, child volumes, and denied members | control absent or disabled, explicit rejection before `FM_Zipper`/`PharData`, no archive and no manifest delta | +| Guarded-disabled | Archive extract | ordinary and crafted ZIP/TAR requests including overwrite, `../`, absolute path, symlink entry, and mount/link destinations | explicit rejection before `extractTo`, no destination creation, both escape sentinels and full manifest unchanged | + +Also exercise every single-item and batch route separately; they do not necessarily share implementation. A route that passes only because the kernel denied it is not equivalent to application-level confinement. Record both layers. + +## Symlink confinement matrix + +Create each link as both a file-facing and directory-facing case where possible: + +- relative link to an object inside `AFS_TEST_ROOT`; +- absolute link to an object inside `AFS_TEST_ROOT`; +- link to `AFS_ESCAPE_ROOT` on the same AFS device/cell; +- link to the cross-volume AFS root; +- link to `LOCAL_ESCAPE_ROOT` on the local filesystem; +- broken link; +- two-link chain and a loop. + +For every link, test list, navigate, view, edit/save, backup, upload through a linked directory, download, former direct-link actions, copy, duplicate, move/rename, single delete, batch delete, archive create, and archive extraction. The required confinement result is: + +- all content reads, writes, navigation, search, copy, and upload through a POSIX link fail closed; the initial production-provider contract does not follow even an in-root POSIX link; +- listing may expose only provider-returned no-follow link metadata and must not obtain `readlink` data through an independently resolved pathname; +- acting on the link object itself, such as unlink or rename, is permitted only if the reviewed broker binds the parent and leaf to a no-follow descriptor operation; otherwise it must also fail closed; +- recursive operations must reject the link before traversal, deletion must never affect its target, and link chains or loops must not cause a hang; +- archive create/extract remains guarded-disabled for both ordinary and crafted requests; and +- DirectLink controls must remain absent; ordinary PHP view/navigation remains provider-mediated, while the web server denies guessed raw URLs for both the link and its target. + +Any outside-root read or write is a release blocker. Preserve the fixture and logs for diagnosis; do not continue destructive cases. + +## Volume mount-point and cross-volume matrix + +Use only disposable volumes. Record each mount point with the client tools, its target volume, read-write/read-only status, server, and FID before testing. + +1. Navigate and list a child-volume mount point inside the test root. This logical AFS volume boundary is distinct from a POSIX symlink or kernel mount; the provider must identify it from reviewed AFS metadata and record the crossing. +2. Compare ACL display and effective caller access on the parent, mount point, child-volume root, and descendants. +3. Start single-file copy/read/write operations from inside the child volume and copy files into and out of it, then compare hashes, ACL effects, provider identity evidence, and mount-point preservation. +4. Attempt rename/move across the volume boundary. Require either a clear non-destructive failure or an explicitly implemented copy-and-delete fallback with complete verification. +5. Attempt parent-started search, recursive copy, and recursive delete across the mount-point object. Require a fail-closed boundary result with no child-volume delta. Archive creation is guarded-disabled and must be rejected before any archive walker runs. Renaming or deleting the mount-point object itself must also be rejected unless a separately reviewed AFS mount-management feature is explicitly in scope. +6. Exercise a read-only mount/volume. Writes must fail clearly and leave no partial files or stale upload chunks. +7. Test a POSIX symlink to a child-volume path and a symlink to an AFS path outside the configured root; both follow attempts must be rejected under the symlink policy above. +8. Add a nested kernel mount under the configured root and prove that it is never traversed, even if its device number or apparent path resembles the AFS tree. +9. Remove or make the AFS mount temporarily unavailable and confirm fail-closed behavior without PHP warnings, hangs, or fallback to a local `/afs` directory. + +Never use a production volume merely to test a read-only case. A supposedly read-only path can still expose sensitive data through view, download, direct-link, copy, or archive operations. + +## Rollback and teardown + +Rollback must be prepared before the first mutation and executed even after a failed test. + +1. Stop the disposable web service so no request races with restoration. +2. Preserve final logs, HTTP transcripts, screenshots where useful, raw ACL/access output, and final filesystem manifests. +3. Compare both escape-root sentinels and their ACLs with baseline. Escalate any difference before cleanup. +4. Restore the application configuration from its timestamped backup and verify its checksum. +5. Restore the fixture from the disposable volume snapshot/clone, or restore files and ACLs from the verified baseline. Re-run hashes, type/symlink inventories, mount-point inventory, and `fs listacl` comparisons. +6. If destroying the disposable roots or volumes, verify the run-ID marker, canonical path, cell, volume, and FID immediately before the exact deletion. Use the cell's recoverable volume-destruction procedure where available. +7. Destroy test tokens, exit PAGs, terminate credential-bearing workers, and verify that the old token is no longer accepted. +8. Remove the disposable container/VM and restricted proxy only after evidence is copied to its retained location. +9. Record what was removed, whether a recoverable volume snapshot remains, and the final restore verification result. + +## Exit criteria + +An AFS/AuriStor compatibility claim requires all of the following: + +- no PHP lint, static-test, upstream-check, warning, or parser failures; +- a separately reviewed descriptor-backed provider or equivalent native broker, with every active metadata, ACL, and I/O call site bound to it and no production use of the bundled pathname preview; +- proven web-worker identity and token/PAG isolation for each authorization role; +- exact normal and negative ACL round trips for standard `lrwidka` and AuriStor `A-H`, preserved inherited ACLs, fail-closed MaxACL behavior, correct `k` handling, CSRF rejection, and enforcement evidence; +- all 18 transitional classifications tested in both allowed and denied cases, with provider/broker evidence, no generic-I/O fallback, and no unexplained partial state; +- all five guarded-disabled classifications proven through absent/disabled controls and crafted-request rejection before generic code or outbound network setup runs; +- no outside-root read or mutation through symlinks, archives, mount points, direct links, or cross-volume operations; +- documented, acceptable behavior for file ACL requests, read-only volumes, token expiry, unavailable mounts, and cross-volume moves; +- verified AFS-mode rejection of configuration self-write and online viewing, absence of raw media/hover and managed-root direct URLs, and web-server denial of guessed managed paths; +- the exact application-owned 13-directive CSP, refactored nonce/hash-compatible templates, one response header, and complete canonical-manifest/transitive asset evidence with a clean browser trace; +- one exact combined application/container image that supplies and checksum-locks `afs_contract.php`, `afs.php`, the provider, application, schema/manifest/assets, AFS client and mount inventory, external-auth bootstrap, worker identity/PAG/token, canonical origin/Host policy, trusted-proxy policy, egress policy, and static-file denial; +- complete evidence and a verified rollback/teardown. + +If a transitional endpoint lacks the reviewed descriptor boundary or exact-image live evidence, report it as transitional/unsupported rather than a compatibility pass. If an operation remains deliberately disabled, preserve and report that rejection instead of implying feature support. diff --git a/tests/afs_io_path_audit.php b/tests/afs_io_path_audit.php new file mode 100644 index 00000000..a9dc0738 --- /dev/null +++ b/tests/afs_io_path_audit.php @@ -0,0 +1,937 @@ + 0, + 'GUARDED-DISABLED' => 0, + 'LIVE-YFS' => 0, + 'PROTECTED' => 0, + 'XFAIL' => 0 +); +$noRawAfsUrls = false; + +function audit_fail($message) +{ + global $auditFailures; + $auditFailures[] = $message; + echo 'FAIL: ' . $message . "\n"; +} + +function audit_assert($condition, $message) +{ + global $auditAssertions; + $auditAssertions++; + if (!$condition) { + audit_fail($message); + return false; + } + return true; +} + +function audit_section($source, $startMarker, $endMarker, $label) +{ + $start = strpos($source, $startMarker); + $end = $start === false ? false + : strpos($source, $endMarker, $start + strlen($startMarker)); + + audit_assert($start !== false, $label . ' start marker is missing'); + audit_assert( + $end !== false && $start !== false && $end > $start, + $label . ' end marker is missing or reordered' + ); + if ($start === false || $end === false || $end <= $start) { + return ''; + } + return substr($source, $start, $end - $start); +} + +function audit_tail($source, $startMarker, $label) +{ + $start = strpos($source, $startMarker); + audit_assert($start !== false, $label . ' start marker is missing'); + return $start === false ? '' : substr($source, $start); +} + +function audit_ordered($source, $needles) +{ + $offset = 0; + foreach ($needles as $needle) { + $position = strpos($source, $needle, $offset); + if ($position === false) { + return false; + } + $offset = $position + strlen($needle); + } + return true; +} + +function audit_matching_lines($source, $needle) +{ + $matches = array(); + foreach (preg_split('/\r?\n/', $source) as $line) { + if (strpos($line, $needle) !== false) { + $matches[] = $line; + } + } + return implode("\n", $matches); +} + +function audit_classify($status, $name, $condition, $detail) +{ + global $auditAssertions, $auditClassifications, $auditCounts, + $noRawAfsUrls; + + $auditAssertions++; + $auditClassifications++; + $known = array_key_exists($status, $auditCounts); + $condition = $condition && $noRawAfsUrls; + if (!$known || !$condition) { + audit_fail($name . ' no longer matches its ' . $status . ' baseline'); + return; + } + + $auditCounts[$status]++; + echo $status . ': ' . $name . ' - ' . $detail . "\n"; +} + +echo "AFS current I/O path audit\n"; + +// Request routes. +$saveRoute = audit_section( + $manager, '// save editor file', '// backup files', 'save route'); +$searchRoute = audit_section( + $manager, '//search : get list of files from the current folder', + 'if(FM_READONLY){', 'AJAX search route'); +$backupRoute = audit_section( + $manager, '// backup files', '// Save Config', 'backup route'); +$urlUploadRoute = audit_section( + $manager, '//upload using url', '// Delete file / folder', + 'URL-upload route'); +$deleteRoute = audit_section( + $manager, '// Delete file / folder', '// Create a new file/folder', + 'single-delete route'); +$createRoute = audit_section( + $manager, '// Create a new file/folder', '// Copy folder / file', + 'create route'); +$copyRoute = audit_section( + $manager, '// Copy folder / file', '// Mass copy files/ folders', + 'single-copy route'); +$massCopyRoute = audit_section( + $manager, '// Mass copy files/ folders', '// Rename', 'mass-copy route'); +$renameRoute = audit_section( + $manager, '// Rename', '// Download', 'rename route'); +$downloadRoute = audit_section( + $manager, '// Download', '// Upload', 'download route'); +$uploadRoute = audit_section( + $manager, '// Upload', '// Mass deleting', 'upload route'); +$massDeleteRoute = audit_section( + $manager, '// Mass deleting', '// Pack files zip, tar', + 'mass-delete route'); +$archiveCreateRoute = audit_section( + $manager, '// Pack files zip, tar', '// Unpack zip, tar', + 'archive-create route'); +$archiveExtractRoute = audit_section( + $manager, '// Unpack zip, tar', '// Change POSIX permissions', + 'archive-extract route'); +$aclPostRoute = audit_section( + $manager, '// Change AFS ACLs', '/*************************** ACTIONS', + 'ACL mutation route'); +$navigationRoute = audit_section( + $manager, + "/*************************** ACTIONS ***************************/\n\n// get current path", + '// upload form', 'navigation/list route'); +$uploadPage = audit_section( + $manager, '// upload form', '// file viewer', + 'upload page and client script'); +$urlUploadClient = audit_section( + $manager, + " \n" + . ' // Upload files using URL @param {Object}', + ' // Search template', + 'footer URL-upload client script'); +$viewerRoute = audit_section( + $manager, '// file viewer', '// file editor', 'file-view route'); +$editorRoute = audit_section( + $manager, '// file editor', '// chmod (not for Windows or AFS)', + 'file-editor route'); +$aclGetRoute = audit_section( + $manager, '// Edit AFS ACLs', '// --- TINYFILEMANAGER MAIN ---', + 'ACL editor route'); +$listingRoute = audit_section( + $manager, '// --- TINYFILEMANAGER MAIN ---', '// --- END HTML ---', + 'main listing'); +$rootUrlBlock = audit_section( + $manager, '// abs path for site. AFS mode uses', '// logout', + 'AFS controller/raw-root URL block'); +$featureConstants = audit_section( + $manager, "if (\$afsSupport && ((defined('FM_SETTINGS_ENABLED')", + '$afsDataPlane = null;', 'production feature constants'); +$profileBootstrap = audit_section( + $manager, "if (is_readable(__DIR__ . '/afs_contract.php')) {", + "define('ACE_FONTSIZE'", 'production profile bootstrap'); +$rootBinding = audit_section( + $manager, '// update root path', "defined('FM_LANG')", + 'production root binding'); + +// Provider-aware wrappers. Each guard condition below proves that the AFS +// branch precedes the generic pathname fallback in the same function. +$aclReadHelper = audit_section( + $manager, 'function fm_read_afs_acl(', + "\nfunction fm_change_afs_acl_entries(", 'ACL read helper'); +$aclChangeHelper = audit_section( + $manager, 'function fm_change_afs_acl_entries(', + "\nfunction fm_get_afs_acl_access(", 'ACL mutation helper'); +$aclAccessHelper = audit_section( + $manager, 'function fm_get_afs_acl_access(', + "\nfunction fm_resolve_existing_path(", 'caller-access helper'); +$resolveHelper = audit_section( + $manager, 'function fm_resolve_existing_path(', + "\nfunction fm_resolve_write_path(", 'resolve helper'); +$resolveWriteHelper = audit_section( + $manager, 'function fm_resolve_write_path(', + "\nfunction fm_inspect_path(", 'write-path resolver'); +$inspectHelper = audit_section( + $manager, 'function fm_inspect_path(', + "\nfunction fm_path_exists(", 'inspect helper'); +$existsHelper = audit_section( + $manager, 'function fm_path_exists(', + "\nfunction fm_read_file_contents(", 'exists helper'); +$readHelper = audit_section( + $manager, 'function fm_read_file_contents(', + "\nfunction fm_write_file_contents(", 'read helper'); +$writeHelper = audit_section( + $manager, 'function fm_write_file_contents(', + "\nfunction fm_create_file(", 'write helper'); +$createHelper = audit_section( + $manager, 'function fm_create_file(', + "\nfunction fm_import_file(", 'create helper'); +$importHelper = audit_section( + $manager, 'function fm_import_file(', + "\nfunction fm_afs_archives_supported(", 'import helper'); +$archiveGateHelper = audit_section( + $manager, 'function fm_afs_archives_supported(', + "\n/**\n * Delete file or folder", 'archive gate helper'); +$deleteHelper = audit_section( + $manager, 'function fm_rdelete(', + "\n/**\n * Recursive chmod", 'delete helper'); +$renameHelper = audit_section( + $manager, 'function fm_rename(', + "\n/**\n * Copy file or folder", 'rename helper'); +$recursiveCopyHelper = audit_section( + $manager, 'function fm_rcopy(', + "\n\n/**\n * Safely create folder", 'recursive-copy helper'); +$mkdirHelper = audit_section( + $manager, 'function fm_mkdir(', + "\n/**\n * Safely copy file", 'mkdir helper'); +$copyHelper = audit_section( + $manager, 'function fm_copy(', + "\n/**\n * Get mime type", 'copy helper'); +$mimeHelper = audit_section( + $manager, 'function fm_get_mime_type(', + "\n/**\n * HTTP Redirect", 'MIME helper'); +$sizeHelper = audit_section( + $manager, 'function fm_get_size(', + "\n\n/**\n * Get nice filesize", 'size helper'); +$searchHelper = audit_section( + $manager, 'function scan(', + "\n/**\n * Parameters: downloadFile", 'search helper'); +$afsDownloadHelper = audit_section( + $manager, 'function fm_afs_download_file(', + "\nfunction fm_download_file(", 'AFS download helper'); +$downloadHelper = audit_section( + $manager, 'function fm_download_file(', + "\n/**\n * Class to work with zip files", 'download helper'); + +$resolveGuard = audit_ordered($resolveHelper, array( + 'if (fm_is_afs_mode())', '$provider->resolveExistingPath(', + "if ((\$type === 'file' && !is_file(\$path))")); +$resolveWriteGuard = audit_ordered($resolveWriteHelper, array( + 'if (fm_is_afs_mode())', '$provider->resolveWritePath(', + 'return $path;')); +$inspectGuard = audit_ordered($inspectHelper, array( + 'if (fm_is_afs_mode())', '$provider->inspectPath(', + '$stat = $allowLinkObject ? @lstat($path) : @stat($path);')); +$existsGuard = audit_ordered($existsHelper, array( + 'if (fm_is_afs_mode())', 'fm_inspect_path(', 'file_exists($path)')); +$readGuard = audit_ordered($readHelper, array( + 'if (fm_is_afs_mode())', '$provider->readContents(', + '@file_get_contents($path)')); +$writeGuard = audit_ordered($writeHelper, array( + 'if (fm_is_afs_mode())', '$provider->writeFile(', + '$handle = @fopen($path')) + && strpos($writeHelper, ') === true;') !== false; +$createGuard = audit_ordered($createHelper, array( + 'if (fm_is_afs_mode())', '$provider->createFile(', + '$handle = @fopen($path')) + && strpos($createHelper, ') === true;') !== false; +$importGuard = audit_ordered($importHelper, array( + 'if (fm_is_afs_mode())', '$provider->importFile(', + '$input = @fopen($source')) + && strpos($importHelper, '$append) === true;') !== false; +$deleteGuard = audit_ordered($deleteHelper, array( + 'if (fm_is_afs_mode())', '$provider->removePath(', + 'if (is_link($path))')) + && strpos($deleteHelper, ') === true;') !== false; +$renameGuard = $inspectGuard && audit_ordered($renameHelper, array( + 'if (fm_is_afs_mode())', 'fm_inspect_path(', + '$provider->renamePath(', 'if (!is_dir($old))')) + && strpos($renameHelper, '$result === true') !== false; +$recursiveCopyGuard = audit_ordered($recursiveCopyHelper, array( + 'if (fm_is_afs_mode())', '$provider->copyPath(', + 'if (!is_dir($path)')) + && strpos($recursiveCopyHelper, ') === true;') !== false; +$mkdirGuard = $resolveGuard && audit_ordered($mkdirHelper, array( + 'if (fm_is_afs_mode())', 'fm_resolve_existing_path(', + '$provider->makeDirectory(', 'if (file_exists($dir))')) + && strpos($mkdirHelper, ') === true;') !== false; +$copyGuard = audit_ordered($copyHelper, array( + 'if (fm_is_afs_mode())', '$provider->copyPath(', + '$time1 = filemtime($f1)')) + && strpos($copyHelper, ') === true;') !== false; +$mimeGuard = audit_ordered($mimeHelper, array( + 'if (fm_is_afs_mode())', '$provider->detectMimeType(', + "if (function_exists('finfo_open'))")); +$sizeGuard = audit_ordered($sizeHelper, array( + 'if (fm_is_afs_mode())', 'fm_inspect_path($file)', + "static \$iswin = null;")); +$searchGuard = audit_ordered($searchHelper, array( + 'if (fm_is_afs_mode())', '$provider->searchFiles(', + 'new RecursiveDirectoryIterator($path)')); +$downloadGuard = audit_ordered($downloadHelper, array( + 'if (fm_is_afs_mode())', 'fm_afs_download_file(', + '$size = filesize($fileLocation)')) + && audit_ordered($afsDownloadHelper, array( + '$provider->openRead(', '@fstat($handle)', 'fread($handle')); +$aclReadGuard = audit_ordered($aclReadHelper, array( + 'fm_afs_provider()', '$provider->readAcl(', 'return is_array($acl)')); +$aclChangeGuard = audit_ordered($aclChangeHelper, array( + 'fm_afs_provider()', '$provider->changeAclEntries(', '=== true;')); +$aclAccessGuard = audit_ordered($aclAccessHelper, array( + 'fm_afs_provider()', '$provider->getACLAccess(', + "preg_match('/^[lrwidkaA-H]{0,15}$/", "? \$rights : '';")); + +$productionProfileValidator = audit_section( + $afs, 'public static function validateProductionProfile(', + 'public static function applicationTemplatesSupportStrictCsp(', + 'production profile validator'); + +// URL upload remains available by default to non-AFS deployments, but the +// immutable AFS profile and its final constant disable the entire egress path. +$urlUploadDefaultPos = strpos($manager, '$url_upload_enabled = true;'); +$configIncludePos = strpos($manager, '@include($config_file);'); +$urlUploadProfileGate = $urlUploadDefaultPos !== false + && $configIncludePos !== false + && $urlUploadDefaultPos < $configIncludePos + && strpos( + $productionProfileValidator, + "'url_upload_enabled' => false") !== false + && audit_ordered($featureConstants, array( + "defined('FM_URL_UPLOAD_ENABLED')", + 'FM_URL_UPLOAD_ENABLED !== false', + "define('FM_URL_UPLOAD_ENABLED', \$url_upload_enabled)", + 'FM_URL_UPLOAD_ENABLED !== false')); + +$urlUploadGatePos = strpos( + $urlUploadRoute, + 'if ($urlUploadRequested && FM_URL_UPLOAD_ENABLED !== true)' +); +$urlUploadDenyExitPos = $urlUploadGatePos === false ? false + : strpos($urlUploadRoute, 'exit();', $urlUploadGatePos); +$urlUploadParsePos = strpos( + $urlUploadRoute, 'parse_url($url, PHP_URL_HOST)'); +$urlUploadTempPos = strpos( + $urlUploadRoute, 'tempnam(sys_get_temp_dir(), "upload-")'); +$urlUploadCopyPos = strpos( + $urlUploadRoute, 'copy($url, $temp_file, $ctx)'); +$urlUploadHandlerGate = strpos( + $urlUploadRoute, + '$urlUploadRequested = isset($_POST[\'type\'])') !== false + && strpos($urlUploadRoute, "\$_POST['type'] === 'upload'") !== false + && strpos( + $urlUploadRoute, + "array_key_exists('uploadurl', \$_REQUEST)") !== false + && $urlUploadGatePos !== false + && strpos($urlUploadRoute, "header('HTTP/1.1 403 Forbidden');") !== false + && strpos( + $urlUploadRoute, + "'message' => 'URL upload is disabled'") !== false + && $urlUploadDenyExitPos !== false + && $urlUploadParsePos !== false + && $urlUploadTempPos !== false + && $urlUploadCopyPos !== false + && $urlUploadGatePos < $urlUploadDenyExitPos + && $urlUploadDenyExitPos < $urlUploadParsePos + && $urlUploadDenyExitPos < $urlUploadTempPos + && $urlUploadDenyExitPos < $urlUploadCopyPos; + +$urlUploadUiGuard = ''; +$urlUploadTabGuardPos = strpos($uploadPage, $urlUploadUiGuard); +$urlUploadTabPos = strpos($uploadPage, 'href="#urlUploader"'); +$urlUploadTabEndPos = $urlUploadTabPos === false ? false + : strpos($uploadPage, '', $urlUploadTabPos); +$urlUploadFormGuardPos = $urlUploadTabEndPos === false ? false + : strpos($uploadPage, $urlUploadUiGuard, $urlUploadTabEndPos); +$urlUploadFormPos = strpos($uploadPage, 'id="js-form-url-upload"'); +$urlUploadFormEndPos = $urlUploadFormPos === false ? false + : strpos($uploadPage, '', $urlUploadFormPos); +$urlUploadUiGate = substr_count($uploadPage, $urlUploadUiGuard) === 2 + && $urlUploadTabGuardPos !== false && $urlUploadTabPos !== false + && $urlUploadTabEndPos !== false + && $urlUploadTabGuardPos < $urlUploadTabPos + && $urlUploadTabPos < $urlUploadTabEndPos + && $urlUploadFormGuardPos !== false && $urlUploadFormPos !== false + && $urlUploadFormEndPos !== false + && $urlUploadFormGuardPos < $urlUploadFormPos + && $urlUploadFormPos < $urlUploadFormEndPos; + +$urlUploadScriptGuardPos = strpos($urlUploadClient, $urlUploadUiGuard); +$urlUploadScriptPos = strpos( + $urlUploadClient, + 'function upload_from_url($this)' +); +$urlUploadScriptEndPos = $urlUploadScriptPos === false ? false + : strpos($urlUploadClient, '', $urlUploadScriptPos); +$urlUploadScriptGate = substr_count( + $urlUploadClient, $urlUploadUiGuard) === 1 + && $urlUploadScriptGuardPos !== false + && $urlUploadScriptPos !== false && $urlUploadScriptEndPos !== false + && $urlUploadScriptGuardPos < $urlUploadScriptPos + && $urlUploadScriptPos < $urlUploadScriptEndPos; + +audit_assert( + $urlUploadProfileGate, + 'AFS URL-upload profile/default/final-constant gate changed' +); +audit_assert( + $urlUploadHandlerGate, + 'AFS URL-upload denial no longer precedes parse/temp/network I/O' +); +audit_assert( + $urlUploadUiGate, + 'disabled URL-upload tab or form can be emitted' +); +audit_assert( + $urlUploadScriptGate, + 'disabled URL-upload JavaScript can be emitted' +); + +// A single raw-URL invariant is conjoined with every route classification. +// FM_ROOT_URL may remain in explicit non-AFS branches, but an AFS link/view +// must stay on FM_SELF_URL/?p= and external viewers/media must stay disabled. +$directLinkLines = audit_matching_lines($listingRoute, "lng('DirectLink')"); +$noRawAfsUrls = audit_ordered($rootUrlBlock, array( + 'if ($afsSupport)', '$afsSelfUrl', + "defined('FM_ROOT_URL') && FM_ROOT_URL !== ''", + 'fm_afs_readiness_error(', "define('FM_ROOT_URL', '')", + '} else {', "define('FM_ROOT_URL', (\$is_https")) + && audit_ordered($viewerRoute, array( + '$file_url = $afsSupport', '? FM_SELF_URL', ': FM_ROOT_URL')) + && audit_ordered($editorRoute, array( + '$file_url = $afsSupport', '? FM_SELF_URL', ': FM_ROOT_URL')) + && strpos($viewerRoute, 'if (!$afsSupport && $is_onlineViewer)') !== false + && substr_count( + $viewerRoute, + 'elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_') === 3 + && strpos( + $productionProfileValidator, + "'direct_links_enabled' => false") !== false + && strpos( + $productionProfileValidator, + "'raw_previews_enabled' => false") !== false + && audit_ordered($featureConstants, array( + "defined('FM_DIRECT_LINKS_ENABLED')", + 'FM_DIRECT_LINKS_ENABLED !== false', + "define('FM_DIRECT_LINKS_ENABLED', \$direct_links_enabled)", + 'FM_DIRECT_LINKS_ENABLED !== false', + 'fm_afs_readiness_error(')) + && substr_count($directLinkLines, 'FM_ROOT_URL') === 2 + && substr_count($directLinkLines, 'href="?p=') === 2 + && substr_count( + $listingRoute, + '') === 1 + && substr_count( + $listingRoute, + '') === 1; +audit_assert( + $noRawAfsUrls, + 'global AFS raw protected-URL invariant changed' +); + +// The side-effect-free provider contract and pathname preview. These source +// checks are not a production descriptor-boundary claim. +$factoryInterface = audit_section( + $contract, 'interface AfsDataPlaneProviderFactory', + 'interface AfsDataPlaneProvider', 'provider factory interface'); +$providerInterface = audit_tail( + $contract, 'interface AfsDataPlaneProvider', 'provider interface'); +$dataPlane = audit_tail( + $afs, 'class AfsDataPlane extends Afs implements AfsDataPlaneProvider', + 'bundled data-plane provider'); +$productionReadyMethod = audit_section( + $dataPlane, 'public function isProductionReady()', + 'public function getReadinessFailure()', 'production readiness method'); +$strictCspMethod = audit_section( + $afs, 'public static function applicationTemplatesSupportStrictCsp()', + 'public static function validateContentSecurityPolicy(', + 'strict-CSP readiness method'); +$canonicalCspDefinition = audit_section( + $afs, 'const LOCAL_ONLY_CONTENT_SECURITY_POLICY =', + 'public static function validateProductionProfile(', + 'canonical CSP definition'); +$manifestFileBuilder = audit_section( + $afs, 'public static function buildLocalAssetTagsFromManifestFile(', + 'public static function validateLocalAsset(', + 'asset manifest-file builder'); +$inspectMethod = audit_section( + $dataPlane, 'public function inspectPath(', + 'public function listDirectory(', 'provider inspect method'); +$copyMethod = audit_section( + $dataPlane, 'public function copyPath(', + 'public function renamePath(', 'provider copy method'); +$renameMethod = audit_section( + $dataPlane, 'public function renamePath(', + 'public function removePath(', 'provider rename method'); +$removeMethod = audit_section( + $dataPlane, 'public function removePath(', + 'protected function resolveObjectPath(', 'provider remove method'); +$resolveMethod = audit_section( + $dataPlane, 'protected function resolveConfinedPath(', + 'protected function validateOpenHandle(', 'provider resolver'); +$searchMethod = audit_section( + $dataPlane, 'protected function searchDirectory(', + 'protected function preflightRecursiveTree(', 'provider search walk'); +$preflightMethod = audit_section( + $dataPlane, 'protected function preflightRecursiveTree(', + 'protected function copyResolvedPath(', 'provider recursive preflight'); +$mountProbeMethod = audit_section( + $dataPlane, 'protected function probeAfsVolumeMountPoint(', + 'protected function loadKernelMountPoints(', 'provider volume probe'); + +// Exact route classes. +audit_classify( + 'TRANSITIONAL', 'save/edit writes', + $resolveGuard && $writeGuard && $readGuard + && strpos($saveRoute, 'fm_write_file_contents(') !== false + && strpos($editorRoute, 'fm_write_file_contents(') !== false + && strpos($saveRoute . $editorRoute, 'fopen($file_path') === false, + 'AJAX and form saves resolve and write through the provider; descriptor implementation absent' +); +audit_classify( + 'TRANSITIONAL', 'backup writes', + $resolveGuard && $copyGuard + && audit_ordered($backupRoute, array( + 'fm_resolve_existing_path(', 'fm_copy(')) + && preg_match('/(? false") !== false + && audit_ordered($featureConstants, array( + "defined('FM_DIRECT_LINKS_ENABLED')", + 'FM_DIRECT_LINKS_ENABLED !== false', + "define('FM_DIRECT_LINKS_ENABLED', \$direct_links_enabled)", + 'FM_DIRECT_LINKS_ENABLED !== false')) + && substr_count($directLinkLines, 'href="?p=') === 2 + && substr_count($directLinkLines, 'FM_ROOT_URL') === 2 + && substr_count( + $listingRoute, + '') === 1 + && substr_count( + $listingRoute, + '') === 1, + 'production rejects enabled constants and omits direct-link controls; ordinary view/download remain transitional' +); +audit_classify( + 'GUARDED-DISABLED', 'archive creation', + strpos($archiveGateHelper, 'return !fm_is_afs_mode();') !== false + && audit_ordered($archiveCreateRoute, array( + 'if (!fm_afs_archives_supported())', 'chdir($path)', + 'new FM_Zipper()')) + && strpos($listingRoute, 'if (fm_afs_archives_supported()):') !== false, + 'AFS rejects the request before chdir/ZipArchive/PharData and hides archive UI' +); +audit_classify( + 'GUARDED-DISABLED', 'archive extraction', + strpos($archiveGateHelper, 'return !fm_is_afs_mode();') !== false + && audit_ordered($archiveExtractRoute, array( + 'if (!fm_afs_archives_supported())', + "is_file(\$path . '/' . \$unzip)", 'extractTo(')), + 'AFS rejects extraction before any archive pathname read or write' +); +audit_classify( + 'TRANSITIONAL', 'symlink traversal and link-object mutation', + $inspectGuard && $deleteGuard && $renameGuard + && strpos($providerInterface, 'inspectPath(') !== false + && strpos($resolveMethod, 'POSIX symbolic links are not traversable') !== false + && strpos($copyMethod, '$source = $this->resolveExistingPath(') !== false + && strpos($renameMethod, '$source = $this->resolveObjectPath(') !== false + && strpos($removeMethod, '$info[\'type\'] === \'link\'') !== false + && strpos($removeMethod, 'return @unlink( $path );') !== false + && strpos($listingRoute, "fm_enc(\$info['link_target'])") !== false, + 'traversal/copy fail closed while final-link rename/delete operate on and escape the link object' +); +audit_classify( + 'LIVE-YFS', 'AFS volume mount-point traversal and mutation', + strpos($afs, 'exact mutation semantics still require live YFS tests') !== false + && strpos($resolveMethod, 'probeAfsVolumeMountPoint(') !== false + && strpos($resolveMethod, 'Unable to classify an AFS volume mount point') !== false + && strpos($searchMethod, 'a parent search never crosses it') !== false + && strpos($preflightMethod, 'Recursive mutation stops at an AFS volume mount point') !== false + && audit_ordered($mountProbeMethod, array( + '$this->runFs( array( \'lsmount\'', '$this->lastFsStatus === 0', + '$this->lastFsStatus !== 0', 'return null;')), + 'classification failures are closed and recursive mutation stops; real YFS volume behavior remains live-only' +); + +// Six additional surfaces made explicit by the provider/readiness lane. +audit_classify( + 'TRANSITIONAL', 'navigation and directory listing', + $resolveGuard && $inspectGuard + && audit_ordered($navigationRoute, array( + 'fm_resolve_existing_path($path, \'dir\')', + 'if ($afsSupport)', 'fm_afs_provider()->listDirectory($path)', + 'scandir($path)')) + && strpos($navigationRoute, 'fm_inspect_path($new_path, true)') !== false, + 'current directory, entries, metadata, and link objects are provider-checked before scandir fallback' +); +audit_classify( + 'TRANSITIONAL', 'recursive search', + $searchGuard + && strpos($searchRoute, '$response = scan(') !== false, + 'AJAX search dispatches to provider searchFiles before RecursiveDirectoryIterator fallback' +); +audit_classify( + 'TRANSITIONAL', 'ACL read/write and caller-access UI', + $resolveGuard && $aclReadGuard && $aclChangeGuard && $aclAccessGuard + && strpos($providerInterface, 'public function readAcl(') !== false + && strpos($providerInterface, 'public function changeAclEntries(') !== false + && strpos($providerInterface, 'public function getACLAccess(') !== false + && strpos($aclPostRoute, 'fm_read_afs_acl(') !== false + && strpos($aclPostRoute, 'fm_change_afs_acl_entries(') !== false + && strpos($aclGetRoute, 'fm_read_afs_acl(') !== false + && substr_count($listingRoute, 'fm_get_afs_acl_access(') === 2 + && strpos($manager, 'new Afs(') === false, + 'ACL subprocess ownership is part of the provider contract; no route instantiates legacy Afs directly' +); +audit_classify( + 'TRANSITIONAL', 'MIME and file metadata', + $inspectGuard && $mimeGuard && $sizeGuard + && strpos($providerInterface, 'public function detectMimeType(') !== false + && strpos($viewerRoute, '$fileInfo = fm_inspect_path(') !== false + && strpos($viewerRoute, 'fm_get_mime_type(') !== false + && strpos($editorRoute, 'fm_get_mime_type(') !== false, + 'provider owns stat-like metadata and content sampling before finfo/filesize fallbacks' +); +audit_classify( + 'GUARDED-DISABLED', 'raw protected URLs and external document viewers', + strpos($manager, '$online_viewer = false;') !== false + && strpos($manager, + "defined('FM_DOC_VIEWER') && FM_DOC_VIEWER !== false") !== false + && strpos( + $productionProfileValidator, + "'raw_previews_enabled' => false") !== false + && strpos($featureConstants, + 'FM_RAW_PREVIEWS_ENABLED !== false') !== false + && $noRawAfsUrls, + 'AFS protected objects are never exposed through FM_ROOT_URL or delegated to online viewers/media tags' +); + +$readinessBlock = audit_section( + $manager, '$afsReadinessError = \'\';', + '// --- EDIT BELOW CAREFULLY OR DO NOT EDIT AT ALL ---', + 'top-level readiness block'); +$providerInit = audit_section( + $manager, '$afsDataPlane = null;', '// always use ?p=', + 'provider initialization block'); +audit_classify( + 'TRANSITIONAL', 'production readiness gate', + audit_ordered($profileBootstrap, array( + "is_readable(__DIR__ . '/afs_contract.php')", + "require_once __DIR__ . '/afs_contract.php'", + '$config_file', '@include($config_file)', + "(\$afsSupport || defined('AFS_PRODUCTION_PROFILE'))", + "interface_exists('AfsDataPlaneProviderFactory', false)", + 'fm_afs_readiness_error(', + "if (\$afsSupport || defined('AFS_PRODUCTION_PROFILE'))", + "require_once __DIR__ . '/afs.php'", + '$afsSelfUrl', '$afsRequestIdentity', '$afsDataRoot = $root_path', + "'profile' => defined('AFS_PRODUCTION_PROFILE')", + "'request_identity' => \$afsRequestIdentity", + "'data_root' => \$afsDataRoot", + "'asset_manifest_sha256' => \$afs_asset_manifest_sha256", + 'AfsProductionReadiness::validateProductionProfile(', + "defined('FM_ROOT_PATH') && FM_ROOT_PATH !== \$afsDataRoot", + 'fm_afs_readiness_error(')) + && strpos( + $afs, + "const PRODUCTION_PROFILE = 'afs-descriptor-v1'") !== false + && audit_ordered($productionProfileValidator, array( + "'afs_enabled' => true", + "'external_auth' => true", + "'local_auth' => false", + "'local_users_empty' => true", + "'settings_enabled' => false", + "'embed_enabled' => false", + "'direct_links_enabled' => false", + "'raw_previews_enabled' => false", + "'url_upload_enabled' => false", + "'root_url' => ''", + "\$state['request_identity']", + "\$state['self_url']", + "\$state['data_root']", + "strpos( \$state['data_root'], '/afs/' ) !== 0", + "rtrim( \$state['data_root'], '/' ) !== \$state['data_root']", + "explode( '/', substr( \$state['data_root'], 5 ))", + "\$segment === '..'", + "\$state['asset_manifest_sha256']", + "preg_match( '/^[a-f0-9]{64}$/',", + "'expected_factory_class'", + "'expected_factory_id'", + "'expected_provider_class'", + "'expected_provider_id'")) + && strpos($factoryInterface, + 'public function getFactoryIdentity();') !== false + && strpos($factoryInterface, + 'public function createProvider( $root, $requestIdentity );') !== false + && strpos($providerInterface, + 'public function getProviderIdentity();') !== false + && strpos($providerInterface, + 'public function getCredentialIdentity();') !== false + && audit_ordered($canonicalCspDefinition, array( + 'const LOCAL_ONLY_CONTENT_SECURITY_POLICY =', + "default-src 'none'", + "base-uri 'none'; connect-src 'self'; font-src 'self'", + "form-action 'self'; frame-ancestors 'none'; frame-src 'none'", + "img-src 'self' data:; media-src 'self'; object-src 'none'", + "script-src 'self'; style-src 'self'; worker-src 'self'")) + && strpos($canonicalCspDefinition, + "font-src 'self' data:") === false + && strpos($afs, + '$policy !== self::LOCAL_ONLY_CONTENT_SECURITY_POLICY') !== false + && audit_ordered($readinessBlock, array( + 'AfsProductionReadiness::buildLocalAssetTagsFromManifestFile(', + '$afs_asset_manifest_file', '$external_asset_root', + '$afs_asset_manifest_sha256', '$afsReadinessError', + 'if ($external === false)', + '$favicon_path !== \'\'', + 'AfsProductionReadiness::validateLocalAsset(', + 'fm_content_security_policy_is_ready(', + '$content_security_policy_approved !== true', + 'headers_list()', + 'Duplicate Content-Security-Policy response header.', + 'header(\'Content-Security-Policy: \' . $content_security_policy, true)', + 'AfsProductionReadiness::applicationTemplatesSupportStrictCsp()', + '!== true', 'fm_afs_readiness_error(')) + && audit_ordered($manifestFileBuilder, array( + '$manifestFile, $assetRoot, $manifestSha256', + 'substr( $manifestFile, 0, 1 ) === \'/\'', + 'foreach ( explode( \'/\', $manifestFile ) as $segment )', + '@lstat( $candidate )', + '@realpath( $candidate )', + '@file_get_contents( $resolved )', + "preg_match( '/^[a-f0-9]{64}$/', \$manifestSha256 )", + "hash_equals( \$manifestSha256, hash( 'sha256', \$raw ))", + 'json_decode( $raw, true )', + '$decoded[\'version\'] !== 1', + 'self::buildLocalAssetTags(')) + && audit_ordered($rootBinding, array( + 'if ($use_auth && isset($_SESSION[FM_SESSION_ID][\'logged\']))', + 'if ($afsSupport)', '$root_path = $afsDataRoot', + 'if (!is_string($root_path))', + '$root_path = rtrim($root_path', + '$root_path = str_replace(\'\\\\\', \'/\', $root_path)', + 'AFS root path must be an absolute pathname.', + "define('FM_ROOT_PATH', \$root_path)", + 'FM_ROOT_PATH !== $afsDataRoot', + 'fm_afs_readiness_error(')) + && substr_count($manager, '$afsDataRoot = $root_path;') === 1 + && substr_count($manager, '$root_path = $afsDataRoot;') === 1 + && audit_ordered($providerInit, array( + 'instanceof AfsDataPlaneProviderFactory', + 'get_class($afsDataPlaneFactory)', + 'getFactoryIdentity()', + '$afsDataPlaneFactory->createProvider(', + 'FM_ROOT_PATH, $afsRequestIdentity', + 'instanceof AfsDataPlaneProvider', + 'get_class($afsDataPlane)', + 'getProviderIdentity()', + 'getCredentialIdentity()', + '!== $afsRequestIdentity', + 'isProductionReady() !== true', + 'getSecurityBoundary()', + 'initializeDataPlane(FM_ROOT_PATH) !== true')) + && substr_count($providerInit, 'FM_ROOT_PATH') === 2 + && audit_ordered($featureConstants, array( + "defined('FM_SETTINGS_ENABLED')", + "defined('FM_DIRECT_LINKS_ENABLED')", + "defined('FM_RAW_PREVIEWS_ENABLED')", + "defined('FM_URL_UPLOAD_ENABLED')", + 'fm_afs_readiness_error(', + "define('FM_SETTINGS_ENABLED', \$settings_enabled)", + "define('FM_DIRECT_LINKS_ENABLED', \$direct_links_enabled)", + "define('FM_RAW_PREVIEWS_ENABLED', \$raw_previews_enabled)", + "define('FM_URL_UPLOAD_ENABLED', \$url_upload_enabled)", + 'FM_SETTINGS_ENABLED !== false', + 'FM_DIRECT_LINKS_ENABLED !== false', + 'FM_RAW_PREVIEWS_ENABLED !== false', + 'FM_URL_UPLOAD_ENABLED !== false', + 'fm_afs_readiness_error(')) + && strpos($productionReadyMethod, 'return false;') !== false + && strpos($dataPlane, + "return 'tinyfilemanager-afs-pathname-preview-v1';") !== false + && strpos($strictCspMethod, 'return false;') !== false + && preg_match('/not a\s+\*\s+production security boundary/', + $afs) === 1, + 'one normalized /afs profile root binds FM_ROOT_PATH, factory, and init; raw JSON manifest bytes, exact self-only CSP, identities, configured credential equality, boundary, and init all fail closed; external-auth/PAG binding remains live-only and the bundled provider remains nonproduction' +); + +// The route inventory is intentionally exact. A new surface must be added and +// classified instead of silently changing these totals. +audit_assert( + $auditClassifications === 24, + 'expected exactly 24 current AFS route/surface classifications' +); +audit_assert( + $auditCounts['TRANSITIONAL'] === 18, + 'expected exactly 18 TRANSITIONAL classifications' +); +audit_assert( + $auditCounts['GUARDED-DISABLED'] === 5, + 'expected exactly 5 GUARDED-DISABLED classifications' +); +audit_assert( + $auditCounts['LIVE-YFS'] === 1, + 'expected exactly 1 LIVE-YFS classification' +); +audit_assert( + $auditCounts['PROTECTED'] === 0, + 'production PROTECTED count must remain zero without a descriptor provider' +); +audit_assert( + $auditCounts['XFAIL'] === 0, + 'current audit must contain no expected failures' +); + +echo 'SUMMARY: ' . $auditCounts['TRANSITIONAL'] . ' TRANSITIONAL, ' + . $auditCounts['GUARDED-DISABLED'] . ' GUARDED-DISABLED, ' + . $auditCounts['LIVE-YFS'] . ' LIVE-YFS, ' + . $auditCounts['PROTECTED'] . ' PROTECTED, ' + . $auditCounts['XFAIL'] . ' XFAIL, ' + . count($auditFailures) . ' failures across ' + . $auditClassifications . ' classifications and ' + . $auditAssertions . " assertions\n"; + +exit(empty($auditFailures) ? 0 : 1); diff --git a/tests/afs_readiness.php b/tests/afs_readiness.php new file mode 100644 index 00000000..833963d0 --- /dev/null +++ b/tests/afs_readiness.php @@ -0,0 +1,1669 @@ + $start, + $label . ' end marker follows its start marker' + ); + + if ($start === false || $end === false || $end <= $start) { + return ''; + } + + return substr($source, $start, $end - $start); +} + +function readiness_remove_tree($path) +{ + if (is_link($path) || is_file($path)) { + @unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + $items = @scandir($path); + if (is_array($items)) { + foreach ($items as $item) { + if ($item !== '.' && $item !== '..') { + readiness_remove_tree($path . '/' . $item); + } + } + } + @rmdir($path); +} + +function readiness_same_keys($actual, $expected) +{ + if (!is_array($actual) || !is_array($expected)) { + return false; + } + $actualKeys = array_keys($actual); + $expectedKeys = array_values($expected); + sort($actualKeys); + sort($expectedKeys); + return $actualKeys === $expectedKeys; +} + +function readiness_without_csp_directive($policy, $directive) +{ + $kept = array(); + foreach (explode(';', $policy) as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + $name = preg_split('/\s+/', $part, 2)[0]; + if ($name !== $directive) { + $kept[] = $part; + } + } + return implode('; ', $kept) . ';'; +} + +echo "AFS readiness contract\n"; + +$configPos = strpos($manager, '@include($config_file);'); +$urlUploadDefaultPos = strpos($manager, '$url_upload_enabled = true;'); +$contractReadablePos = strpos( + $manager, + "if (is_readable(__DIR__ . '/afs_contract.php'))" +); +$contractRequirePos = strpos( + $manager, + "require_once __DIR__ . '/afs_contract.php';" +); +$contractRequiredPos = strpos( + $manager, + "&& !interface_exists('AfsDataPlaneProviderFactory', false)" +); +$viewerOffPos = strpos( + $manager, + '$online_viewer = false;', + $configPos === false ? 0 : $configPos +); +$manifestDefaultPos = strpos($manager, '$afs_asset_manifest_file = \'\';'); +$manifestHashDefaultPos = strpos( + $manager, + '$afs_asset_manifest_sha256 = \'\';' +); +$resourceGuardPos = strpos( + $manager, + 'AfsProductionReadiness::buildLocalAssetTagsFromManifestFile(', + $configPos === false ? 0 : $configPos +); +$cspGuardPos = strpos($manager, 'fm_content_security_policy_is_ready('); +$cspHeaderPos = strpos( + $manager, + "header('Content-Security-Policy: ' . \$content_security_policy" +); +$strictTemplateGatePos = strpos( + $manager, + 'AfsProductionReadiness::applicationTemplatesSupportStrictCsp()' +); +$profileValidationPos = strpos( + $manager, + 'AfsProductionReadiness::validateProductionProfile(' +); +$cspApprovalDefault = strpos( + $manager, + '$content_security_policy_approved = false;' +); +$readinessBlock = readiness_section( + $manager, + "\$afsReadinessError = '';", + "if (\$content_security_policy !== '')", + 'top-level AFS readiness block' +); + +readiness_ok($configPos !== false, 'config.php inclusion is present'); +readiness_ok( + $urlUploadDefaultPos !== false && $configPos !== false + && $urlUploadDefaultPos < $configPos, + 'non-AFS URL upload defaults to literal true before config.php overrides' +); +readiness_ok( + $contractReadablePos !== false && $contractRequirePos !== false + && $configPos !== false + && $contractReadablePos < $contractRequirePos + && $contractRequirePos < $configPos, + 'side-effect-free provider contract loads before config.php' +); +readiness_ok( + $contractRequiredPos !== false + && strpos( + $manager, + 'AFS production requires the packaged provider contract.', + $contractRequiredPos + ) !== false, + 'missing provider contract fails closed whenever AFS is requested' +); +readiness_ok( + strpos($afsSource, "require_once __DIR__ . '/afs_contract.php';") !== false, + 'AFS implementation consumes the same provider contract' +); +readiness_ok( + strpos($contractSource, 'interface AfsDataPlaneProviderFactory') !== false + && strpos($contractSource, 'interface AfsDataPlaneProvider') !== false + && strpos($contractSource, 'class AfsDataPlane') === false, + 'provider contract remains interface-only and runtime-independent' +); +readiness_ok( + $manifestDefaultPos !== false && $configPos !== false + && $manifestHashDefaultPos !== false + && $manifestDefaultPos < $configPos + && $manifestHashDefaultPos < $configPos, + 'canonical AFS manifest file and digest default empty before config.php' +); +readiness_ok( + $configPos !== false && $resourceGuardPos !== false + && $configPos < $resourceGuardPos, + 'canonical JSON local-asset tags are built after config.php overrides' +); +readiness_ok( + substr_count( + $manager, + 'AfsProductionReadiness::buildLocalAssetTagsFromManifestFile(' + ) === 1 + && strpos( + $readinessBlock, + '$afs_asset_manifest_file, $external_asset_root,' + ) !== false + && strpos( + $readinessBlock, + '$afs_asset_manifest_sha256, $afsReadinessError' + ) !== false, + 'AFS runtime consumes one digest-pinned canonical manifest artifact' +); +readiness_ok( + strpos($readinessBlock, 'if ($afsSupport) {') !== false + && strpos( + $readinessBlock, + '} elseif (is_array($external_resources)' + ) !== false, + 'raw external-resource HTML overrides are confined to non-AFS mode' +); +readiness_ok( + strpos($manager, '$afs_asset_manifest = array();') === false, + 'AFS runtime has no independent mutable PHP asset-manifest array' +); +readiness_ok( + strpos($readinessBlock, '$afsSupport && $favicon_path !==') !== false + && strpos($readinessBlock, 'validateLocalAsset(') !== false, + 'configured favicon loads share the AFS local-resource readiness gate' +); +readiness_ok( + strpos($afsSource, 'function validateExternalResources') === false + && strpos($manager, 'fm_external_resources_are_local(') === false, + 'obsolete raw-HTML AFS resource validators are absent' +); +readiness_ok( + $cspGuardPos !== false && strpos( + $readinessBlock, + 'if ($afsSupport && !fm_content_security_policy_is_ready(' + ) !== false, + 'AFS mode calls the CSP readiness predicate' +); +readiness_ok( + $cspApprovalDefault !== false && $configPos !== false + && $cspApprovalDefault < $configPos + && strpos( + $readinessBlock, + 'if ($afsSupport && $content_security_policy_approved !== true)' + ) !== false, + 'AFS CSP review approval defaults off and requires literal true' +); +readiness_ok( + $cspGuardPos !== false && $cspHeaderPos !== false && $cspGuardPos < $cspHeaderPos, + 'AFS CSP readiness validation precedes header emission' +); +readiness_ok( + strpos($manager, 'foreach (headers_list() as $configuredHeader)') !== false + && strpos( + $manager, + 'Duplicate Content-Security-Policy response header.' + ) !== false, + 'AFS readiness rejects a duplicate PHP CSP header source' +); +readiness_ok( + $strictTemplateGatePos !== false + && $cspHeaderPos !== false && $cspHeaderPos < $strictTemplateGatePos + && strpos( + $manager, + 'fm_afs_readiness_error(', + $strictTemplateGatePos + ) !== false, + 'AFS readiness hard-fails while application templates require inline execution' +); +readiness_ok( + $configPos !== false && $viewerOffPos !== false && $configPos < $viewerOffPos, + 'AFS mode overrides the configured online-viewer variable after config.php' +); + +$viewerRoute = readiness_section( + $manager, + '// file viewer', + '// file editor', + 'file-viewer route' +); +$viewerConstantForced = preg_match( + "/define\\('FM_DOC_VIEWER',\\s*\\\$afsSupport\\s*\\?\\s*false\\s*:\\s*\\\$online_viewer\\)/", + $manager +) === 1; +$viewerRouteForced = preg_match( + '/\\$online_viewer\\s*=\\s*fm_is_afs_mode\\(\\)\\s*\\?\\s*false\\s*:/', + $viewerRoute +) === 1; +$predefinedViewerCheck = strpos( + $manager, + "if (\$afsSupport && defined('FM_DOC_VIEWER') && FM_DOC_VIEWER !== false)" +); +$predefinedViewerError = $predefinedViewerCheck === false ? false + : strpos($manager, 'fm_afs_readiness_error(', $predefinedViewerCheck); +$viewerConstantDefinition = strpos( + $manager, + "defined('FM_DOC_VIEWER') || define('FM_DOC_VIEWER', \$online_viewer);" +); +$predefinedViewerRejected = $predefinedViewerCheck !== false + && $predefinedViewerError !== false && $viewerConstantDefinition !== false + && $predefinedViewerCheck < $predefinedViewerError + && $predefinedViewerError < $viewerConstantDefinition; +readiness_ok( + $viewerConstantForced || $viewerRouteForced || $predefinedViewerRejected, + 'AFS mode cannot inherit a pre-defined online-viewer constant from config.php' +); + +$finalFeatureBlock = readiness_section( + $manager, + "if (\$afsSupport && defined('FM_DOC_VIEWER')", + '$afsDataPlane = null;', + 'final AFS feature-constant gate' +); +$predefinedFeatureGate = strpos( + $finalFeatureBlock, + "if (\$afsSupport && ((defined('FM_SETTINGS_ENABLED')" +); +$featureDefinitions = strpos( + $finalFeatureBlock, + "defined('FM_SETTINGS_ENABLED') || define('FM_SETTINGS_ENABLED'" +); +$finalFeatureGate = strpos( + $finalFeatureBlock, + 'if ($afsSupport && (FM_SETTINGS_ENABLED !== false' +); +$urlUploadPredefinedGate = strpos( + $finalFeatureBlock, + "defined('FM_URL_UPLOAD_ENABLED')" +); +$urlUploadDefinition = strpos( + $finalFeatureBlock, + "defined('FM_URL_UPLOAD_ENABLED') || define('FM_URL_UPLOAD_ENABLED', " + . '$url_upload_enabled);' +); +readiness_ok( + $predefinedFeatureGate !== false && $featureDefinitions !== false + && $predefinedFeatureGate < $featureDefinitions, + 'pre-defined settings/direct-link/raw-preview constants fail before use' +); +readiness_ok( + $featureDefinitions !== false && $finalFeatureGate !== false + && $featureDefinitions < $finalFeatureGate + && strpos( + $finalFeatureBlock, + 'FM_DIRECT_LINKS_ENABLED !== false', + $finalFeatureGate + ) !== false + && strpos( + $finalFeatureBlock, + 'FM_RAW_PREVIEWS_ENABLED !== false', + $finalFeatureGate + ) !== false, + 'final settings/direct-link/raw-preview constants remain fail-closed' +); +readiness_ok( + $urlUploadPredefinedGate !== false && $urlUploadDefinition !== false + && $urlUploadPredefinedGate < $urlUploadDefinition + && strpos( + $finalFeatureBlock, + 'FM_URL_UPLOAD_ENABLED !== false', + $urlUploadPredefinedGate + ) !== false, + 'AFS rejects a pre-defined URL-upload constant unless it is literal false' +); +readiness_ok( + $urlUploadDefinition !== false && $finalFeatureGate !== false + && $urlUploadDefinition < $finalFeatureGate + && strpos( + $finalFeatureBlock, + 'FM_URL_UPLOAD_ENABLED !== false', + $finalFeatureGate + ) !== false, + 'AFS rechecks the final URL-upload constant for literal false' +); + +$settingsRoute = readiness_section( + $manager, + '// Save Config', + '// new password hash', + 'settings-write route' +); +$settingsGuard = strpos( + $settingsRoute, + 'if (!FM_SETTINGS_ENABLED || fm_is_afs_mode())' +); +$settingsMutation = strpos($settingsRoute, '$cfg->data['); +$settingsSave = strpos($settingsRoute, '$cfg->save();'); +readiness_ok( + $settingsGuard !== false && $settingsMutation !== false + && $settingsGuard < $settingsMutation, + 'AFS settings rejection precedes in-memory configuration mutation' +); +readiness_ok( + $settingsGuard !== false && $settingsSave !== false + && $settingsGuard < $settingsSave, + 'AFS settings rejection precedes persistent configuration save' +); + +$passwordHashRoute = readiness_section( + $manager, + '// new password hash', + '//upload using url', + 'password-hash utility route' +); +$passwordHashGuard = strpos( + $passwordHashRoute, + 'if (!FM_SETTINGS_ENABLED)' +); +$passwordHashWork = strpos($passwordHashRoute, 'password_hash('); +readiness_ok( + $passwordHashGuard !== false && $passwordHashWork !== false + && $passwordHashGuard < $passwordHashWork, + 'disabled settings gate rejects password-hash utility before work' +); + +$urlUploadRoute = readiness_section( + $manager, + '//upload using url', + '// Delete file / folder', + 'URL-upload handler' +); +$urlUploadRequestedPos = strpos( + $urlUploadRoute, + '$urlUploadRequested = isset(' +); +$urlUploadDisabledPos = strpos( + $urlUploadRoute, + 'if ($urlUploadRequested && FM_URL_UPLOAD_ENABLED !== true)' +); +$urlUploadActivePos = strpos( + $urlUploadRoute, + "if (\$urlUploadRequested && !empty(\$_REQUEST['uploadurl']))" +); +$urlUploadParsePos = strpos($urlUploadRoute, '$url = !empty('); +$urlUploadTempPos = strpos($urlUploadRoute, 'tempnam('); +$urlUploadCurlPos = strpos($urlUploadRoute, 'curl_init('); +$urlUploadStreamPos = strpos($urlUploadRoute, 'copy($url, $temp_file, $ctx)'); +readiness_ok( + $urlUploadRequestedPos !== false && $urlUploadDisabledPos !== false + && $urlUploadActivePos !== false && $urlUploadParsePos !== false + && $urlUploadTempPos !== false && $urlUploadCurlPos !== false + && $urlUploadStreamPos !== false + && $urlUploadRequestedPos < $urlUploadDisabledPos + && $urlUploadDisabledPos < $urlUploadActivePos + && $urlUploadDisabledPos < $urlUploadParsePos + && $urlUploadDisabledPos < $urlUploadTempPos + && $urlUploadDisabledPos < $urlUploadCurlPos + && $urlUploadDisabledPos < $urlUploadStreamPos + && strpos( + $urlUploadRoute, + "header('HTTP/1.1 403 Forbidden');", + $urlUploadDisabledPos + ) !== false, + 'disabled URL upload rejects before parsing, temporary files, or network I/O' +); + +$uploadForm = readiness_section( + $manager, + '// upload form', + '// file viewer', + 'upload-form UI' +); +$urlUiGuard = ''; +$urlTabGuardPos = strpos($uploadForm, $urlUiGuard); +$urlTabPos = strpos($uploadForm, 'href="#urlUploader"'); +$urlTabEndPos = $urlTabPos === false ? false + : strpos($uploadForm, '', $urlTabPos); +$urlFormGuardPos = $urlTabEndPos === false ? false + : strpos($uploadForm, $urlUiGuard, $urlTabEndPos + 1); +$localUploadFormPos = strpos($uploadForm, 'id="fileUploader"'); +$urlFormPos = strpos($uploadForm, 'id="js-form-url-upload"'); +$urlFormEndPos = $urlFormPos === false ? false + : strpos($uploadForm, '', $urlFormPos); +readiness_ok( + $urlTabGuardPos !== false && $urlTabPos !== false + && $urlTabEndPos !== false && $urlFormGuardPos !== false + && $localUploadFormPos !== false && $urlFormPos !== false + && $urlFormEndPos !== false + && $urlTabGuardPos < $urlTabPos && $urlTabPos < $urlTabEndPos + && $urlTabEndPos < $localUploadFormPos + && $localUploadFormPos < $urlFormGuardPos + && $urlFormGuardPos < $urlFormPos && $urlFormPos < $urlFormEndPos, + 'URL-upload tab and form are conditional while local upload remains available' +); + +$urlScriptCommentPos = strpos( + $manager, + '// Upload files using URL @param {Object}' +); +$urlScriptGuardPos = $urlScriptCommentPos === false ? false + : strrpos(substr($manager, 0, $urlScriptCommentPos), $urlUiGuard); +$urlScriptFunctionPos = strpos($manager, 'function upload_from_url(', + $urlScriptCommentPos === false ? 0 : $urlScriptCommentPos); +$urlScriptEndPos = $urlScriptFunctionPos === false ? false + : strpos($manager, '', $urlScriptFunctionPos); +$searchScriptPos = strpos($manager, '// Search template', + $urlScriptFunctionPos === false ? 0 : $urlScriptFunctionPos); +readiness_ok( + $urlScriptGuardPos !== false && $urlScriptCommentPos !== false + && $urlScriptFunctionPos !== false && $urlScriptEndPos !== false + && $searchScriptPos !== false + && $urlScriptGuardPos < $urlScriptCommentPos + && $urlScriptCommentPos < $urlScriptFunctionPos + && $urlScriptFunctionPos < $urlScriptEndPos + && $urlScriptEndPos < $searchScriptPos, + 'URL-upload JavaScript is emitted only when the feature is literally true' +); + +$configSave = readiness_section( + $manager, + ' function save()', + "\n }\n}", + 'FM_Config save method' +); +$saveGuard = strpos($configSave, 'if (fm_is_afs_mode())'); +$saveReturn = strpos($configSave, 'return false;'); +$saveOpen = strpos($configSave, '@fopen('); +readiness_ok( + $saveGuard !== false && $saveReturn !== false && $saveOpen !== false + && $saveGuard < $saveReturn && $saveReturn < $saveOpen, + 'FM_Config::save fails closed before opening configuration for write' +); +readiness_ok( + strpos($manager, 'AFS production configuration is invalid and immutable.') !== false, + 'invalid AFS configuration fails readiness instead of invoking default save' +); + +$expectedLocalOnlyCsp = "default-src 'none'; base-uri 'none'; " + . "connect-src 'self'; font-src 'self'; form-action 'self'; " + . "frame-ancestors 'none'; frame-src 'none'; img-src 'self' data:; " + . "media-src 'self'; object-src 'none'; script-src 'self'; " + . "style-src 'self'; worker-src 'self'"; +$cspConstantName = 'AfsProductionReadiness::LOCAL_ONLY_CONTENT_SECURITY_POLICY'; +$hasCspBaseline = defined($cspConstantName); +$documentedCsp = $hasCspBaseline ? constant($cspConstantName) : null; +readiness_ok( + $hasCspBaseline && $documentedCsp === $expectedLocalOnlyCsp, + 'AFS exposes the documented strict local-origin/resource CSP baseline' +); + +$cspError = null; +readiness_ok( + AfsProductionReadiness::validateContentSecurityPolicy( + $expectedLocalOnlyCsp, $cspError + ) === true, + 'the strict local-origin/resource CSP baseline passes policy validation' +); +readiness_ok( + method_exists( + 'AfsProductionReadiness', + 'applicationTemplatesSupportStrictCsp' + ) && AfsProductionReadiness::applicationTemplatesSupportStrictCsp() === false, + 'current inline application templates explicitly block AFS readiness' +); +$extendedLocalPolicy = str_replace( + "img-src 'self' data:", "img-src 'self'", + $expectedLocalOnlyCsp +); +$cspError = null; +readiness_ok( + AfsProductionReadiness::validateContentSecurityPolicy( + $extendedLocalPolicy, $cspError + ) === false && is_string($cspError) && $cspError !== '', + 'AFS CSP rejects even local-only reductions from the exact 13-directive policy' +); + +$invalidPolicies = array( + 'empty CSP' => '', + 'whitespace-only CSP' => " \t ", + 'non-string CSP' => array($expectedLocalOnlyCsp), + 'CSP with carriage return' => $expectedLocalOnlyCsp . "\rX-Test: bad", + 'CSP with newline' => $expectedLocalOnlyCsp . "\nX-Test: bad", + 'CSP with NUL' => $expectedLocalOnlyCsp . "\0script-src *", + 'non-baseline minimal CSP' => "default-src 'self'; object-src 'none'" +); +foreach ($invalidPolicies as $label => $policy) { + $cspError = null; + readiness_ok( + AfsProductionReadiness::validateContentSecurityPolicy( + $policy, $cspError + ) === false && is_string($cspError) && $cspError !== '', + $label . ' fails readiness with an explicit error' + ); +} + +$requiredCspDirectives = array( + 'default-src', 'base-uri', 'connect-src', 'font-src', 'form-action', + 'frame-ancestors', 'frame-src', 'img-src', 'media-src', 'object-src', + 'script-src', 'style-src', 'worker-src' +); +foreach ($requiredCspDirectives as $directive) { + $cspError = null; + readiness_ok( + AfsProductionReadiness::validateContentSecurityPolicy( + readiness_without_csp_directive($expectedLocalOnlyCsp, $directive), + $cspError + ) === false, + 'CSP readiness requires the ' . $directive . ' baseline directive' + ); +} + +$remoteOrWildcardPolicies = array( + 'HTTP script origin' => str_replace( + "script-src 'self'", "script-src 'self' http://cdn.example.invalid", + $expectedLocalOnlyCsp + ), + 'HTTPS script origin' => str_replace( + "script-src 'self'", "script-src 'self' https://cdn.example.invalid", + $expectedLocalOnlyCsp + ), + 'protocol-relative origin' => str_replace( + "style-src 'self'", "style-src 'self' //cdn.example.invalid", + $expectedLocalOnlyCsp + ), + 'bare remote host' => str_replace( + "font-src 'self'", + "font-src 'self' cdn.example.invalid", + $expectedLocalOnlyCsp + ), + 'remote HTTPS scheme' => str_replace( + "connect-src 'self'", "connect-src 'self' https:", + $expectedLocalOnlyCsp + ), + 'remote WSS origin' => str_replace( + "connect-src 'self'", "connect-src 'self' wss://api.example.invalid", + $expectedLocalOnlyCsp + ), + 'global wildcard' => str_replace( + "default-src 'none'", 'default-src *', $expectedLocalOnlyCsp + ), + 'host wildcard' => str_replace( + "style-src 'self'", "style-src 'self' *.example.invalid", + $expectedLocalOnlyCsp + ), + 'script data scheme' => str_replace( + "script-src 'self'", "script-src 'self' data:", + $expectedLocalOnlyCsp + ), + 'script blob scheme' => str_replace( + "script-src 'self'", "script-src 'self' blob:", + $expectedLocalOnlyCsp + ), + 'worker data scheme' => str_replace( + "worker-src 'self'", "worker-src 'self' data:", + $expectedLocalOnlyCsp + ), + 'worker blob scheme' => str_replace( + "worker-src 'self'", "worker-src 'self' blob:", + $expectedLocalOnlyCsp + ), + 'empty required directive' => str_replace( + "frame-ancestors 'none'", 'frame-ancestors', + $expectedLocalOnlyCsp + ), + 'unsafe inline script' => str_replace( + "script-src 'self'", "script-src 'self' 'unsafe-inline'", + $expectedLocalOnlyCsp + ), + 'unsafe eval script' => str_replace( + "script-src 'self'", "script-src 'self' 'unsafe-eval'", + $expectedLocalOnlyCsp + ), + 'unsafe WebAssembly eval' => str_replace( + "script-src 'self'", "script-src 'self' 'wasm-unsafe-eval'", + $expectedLocalOnlyCsp + ), + 'unsafe inline style' => str_replace( + "style-src 'self'", "style-src 'self' 'unsafe-inline'", + $expectedLocalOnlyCsp + ), + 'remote report endpoint' => $expectedLocalOnlyCsp + . '; report-uri https://reports.example.invalid/csp', + 'comma-appended remote policy' => $expectedLocalOnlyCsp + . ", script-src https://cdn.example.invalid" +); +foreach ($remoteOrWildcardPolicies as $label => $policy) { + $cspError = null; + readiness_ok( + AfsProductionReadiness::validateContentSecurityPolicy( + $policy, $cspError + ) === false, + $label . ' fails CSP readiness' + ); +} + +$productionProfileKeys = array( + 'profile', 'afs_enabled', 'external_auth', 'request_identity', + 'local_auth', 'local_users_empty', 'settings_enabled', 'embed_enabled', + 'direct_links_enabled', 'raw_previews_enabled', 'url_upload_enabled', + 'root_url', 'self_url', + 'data_root', 'asset_manifest_sha256', + 'expected_factory_class', 'expected_factory_id', + 'expected_provider_class', 'expected_provider_id' +); +$profileStateBlock = readiness_section( + $manager, + '$afsProfileState = array(', + 'unset($afsProfileState, $afsProfileError);', + 'manager production-profile state' +); +$providerRuntime = readiness_section( + $manager, + '$afsDataPlane = null;', + '// always use ?p=', + 'AFS provider-factory runtime' +); +$rootBindingBlock = readiness_section( + $manager, + '// update root path', + '$afsDataPlane = null;', + 'AFS production-root binding' +); +$profileValidatorAvailable = method_exists( + 'AfsProductionReadiness', + 'validateProductionProfile' +); +readiness_ok( + $profileValidatorAvailable, + 'immutable AFS production-profile validator is available' +); +readiness_ok( + strpos($afsSource, "'url_upload_enabled' => false") !== false, + 'immutable AFS profile requires URL upload to be literal false' +); +readiness_ok( + defined('AfsProductionReadiness::PRODUCTION_PROFILE') + && AfsProductionReadiness::PRODUCTION_PROFILE === 'afs-descriptor-v1' + && substr_count( + $manager, + "if (\$afsSupport || defined('AFS_PRODUCTION_PROFILE')) {" + ) >= 2, + 'AFS activation requires the exact immutable production-profile constant' +); +readiness_ok( + strpos($manager, '$afs_production_profile') === false, + 'AFS activation has no mutable production-profile configuration variable' +); +readiness_ok( + $profileValidationPos !== false, + 'manager validates its constructed actual production state' +); + +$profileStateMappings = array( + 'profile' => "'profile' => defined('AFS_PRODUCTION_PROFILE')", + 'afs_enabled' => "'afs_enabled' => \$afsSupport", + 'external_auth' => "'external_auth' => \$afs_external_auth", + 'request_identity' => "'request_identity' => \$afsRequestIdentity", + 'local_auth' => "'local_auth' => \$use_auth", + 'local_users_empty' => "'local_users_empty' => is_array(\$auth_users)", + 'settings_enabled' => "'settings_enabled' => \$settings_enabled", + 'embed_enabled' => "'embed_enabled' => defined('FM_EMBED')", + 'direct_links_enabled' => "'direct_links_enabled' => \$direct_links_enabled", + 'raw_previews_enabled' => "'raw_previews_enabled' => \$raw_previews_enabled", + 'url_upload_enabled' => "'url_upload_enabled' => \$url_upload_enabled", + 'root_url' => "'root_url' => \$root_url", + 'self_url' => "'self_url' => \$afsSelfUrl", + 'data_root' => "'data_root' => \$afsDataRoot", + 'asset_manifest_sha256' => + "'asset_manifest_sha256' => \$afs_asset_manifest_sha256", + 'expected_factory_class' => + "'expected_factory_class' => \$afs_expected_factory_class", + 'expected_factory_id' => + "'expected_factory_id' => \$afs_expected_factory_id", + 'expected_provider_class' => + "'expected_provider_class' => \$afs_expected_provider_class", + 'expected_provider_id' => + "'expected_provider_id' => \$afs_expected_provider_id" +); +foreach ($profileStateMappings as $key => $sourceFragment) { + readiness_ok( + strpos($profileStateBlock, $sourceFragment) !== false, + 'manager derives production-profile state field ' . $key + ); +} +readiness_ok( + strpos( + $manager, + "\$afsSelfUrl = isset(\$_SERVER['SCRIPT_NAME'])" + ) !== false + && strpos( + $manager, + "\$afsRequestIdentity = isset(\$_SERVER['REMOTE_USER'])" + ) !== false, + 'manager snapshots controller URL and request identity once' +); +readiness_ok( + strpos($manager, '$afsDataRoot = $root_path;') !== false, + 'manager snapshots one post-config AFS data root' +); +$predefinedRootGate = strpos( + $profileStateBlock, + "if (defined('FM_ROOT_PATH') && FM_ROOT_PATH !== \$afsDataRoot)" +); +readiness_ok( + $predefinedRootGate !== false + && strpos( + $profileStateBlock, + 'Pre-defined FM_ROOT_PATH does not match the production profile.', + $predefinedRootGate + ) !== false, + 'pre-defined FM_ROOT_PATH must exactly match the profile snapshot' +); +$rootSnapshotRestore = strpos($rootBindingBlock, '$root_path = $afsDataRoot;'); +$rootConstantDefinition = strpos( + $rootBindingBlock, + "defined('FM_ROOT_PATH') || define('FM_ROOT_PATH', \$root_path);" +); +$finalRootGate = strpos( + $rootBindingBlock, + 'if ($afsSupport && FM_ROOT_PATH !== $afsDataRoot)' +); +readiness_ok( + $rootSnapshotRestore !== false && $rootConstantDefinition !== false + && $finalRootGate !== false + && $rootSnapshotRestore < $rootConstantDefinition + && $rootConstantDefinition < $finalRootGate, + 'later per-user state cannot change the exact AFS root constant' +); +readiness_ok( + strpos( + $providerRuntime, + '$afsDataPlaneFactory instanceof AfsDataPlaneProviderFactory' + ) !== false + && strpos( + $providerRuntime, + 'get_class($afsDataPlaneFactory) !== $afs_expected_factory_class' + ) !== false + && strpos( + $providerRuntime, + '$afsDataPlaneFactory->getFactoryIdentity()' + ) !== false + && strpos( + $providerRuntime, + '!== $afs_expected_factory_id' + ) !== false, + 'manager requires exact factory class and identity matches' +); +readiness_ok( + strpos($providerRuntime, "\$_SERVER['REMOTE_USER']") === false + && preg_match( + '/->createProvider\(\s*FM_ROOT_PATH,\s*\$afsRequestIdentity\s*\)/', + $providerRuntime + ) === 1, + 'factory uses the exact root constant and snapshotted request identity' +); +readiness_ok( + strpos( + $providerRuntime, + '$afsDataPlane instanceof AfsDataPlaneProvider' + ) !== false + && strpos( + $providerRuntime, + 'get_class($afsDataPlane) !== $afs_expected_provider_class' + ) !== false + && strpos($providerRuntime, '$afsDataPlane->getProviderIdentity()') !== false + && strpos($providerRuntime, '!== $afs_expected_provider_id') !== false, + 'manager requires exact provider class and identity matches' +); +readiness_ok( + strpos($providerRuntime, '$afsDataPlane->getCredentialIdentity()') !== false + && strpos($providerRuntime, '!== $afsRequestIdentity') !== false, + 'provider credential identity must exactly equal REMOTE_USER' +); +readiness_ok( + preg_match( + '/->initializeDataPlane\(\s*FM_ROOT_PATH\s*\)/', + $providerRuntime + ) === 1, + 'provider initialization uses the exact profile-bound root constant' +); +readiness_ok( + $strictTemplateGatePos !== false + && $profileValidationPos !== false + && $profileValidationPos < $strictTemplateGatePos, + 'complete production-profile validation precedes the known CSP-template gate' +); +readiness_ok( + interface_exists('AfsDataPlaneProviderFactory') + && method_exists('AfsDataPlaneProviderFactory', 'getFactoryIdentity') + && method_exists('AfsDataPlaneProviderFactory', 'createProvider'), + 'provider-factory interface exposes identity and credential-aware creation' +); +readiness_ok( + method_exists('AfsDataPlaneProvider', 'getProviderIdentity') + && method_exists('AfsDataPlaneProvider', 'getCredentialIdentity'), + 'provider interface exposes production and credential identities' +); + +if ($profileValidatorAvailable) { + $configuredProfile = array( + 'profile' => 'afs-descriptor-v1', + 'afs_enabled' => true, + 'external_auth' => true, + 'request_identity' => 'alice@example.test', + 'local_auth' => false, + 'local_users_empty' => true, + 'settings_enabled' => false, + 'embed_enabled' => false, + 'direct_links_enabled' => false, + 'raw_previews_enabled' => false, + 'url_upload_enabled' => false, + 'root_url' => '', + 'self_url' => '/tinyfilemanager.php', + 'data_root' => '/afs/example.test/users/alice', + 'asset_manifest_sha256' => str_repeat('a', 64), + 'expected_factory_class' => 'TrustedAfsFactory', + 'expected_factory_id' => 'trusted-factory-v1', + 'expected_provider_class' => 'TrustedAfsProvider', + 'expected_provider_id' => 'trusted-provider-v1' + ); + $validateProfile = function ($state) { + $error = null; + $accepted = AfsProductionReadiness::validateProductionProfile( + $state, $error + ); + return array('accepted' => $accepted, 'error' => $error); + }; + $rejectProfile = function ($state) use ($validateProfile) { + $result = $validateProfile($state); + return $result['accepted'] === false + && is_string($result['error']) && $result['error'] !== ''; + }; + + // This validates configuration shape only. It cannot prove that the web + // server stripped client-supplied identity headers or authenticated the + // REMOTE_USER value; that remains an exact-deployment integration check. + $configuredResult = $validateProfile($configuredProfile); + readiness_ok( + $configuredResult['accepted'] === true + && readiness_same_keys($configuredProfile, $productionProfileKeys), + 'complete configured profile reaches the CSP-template fail-closed gate' + ); + $singleSegmentRootProfile = $configuredProfile; + $singleSegmentRootProfile['data_root'] = '/afs/example.test'; + readiness_ok( + $validateProfile($singleSegmentRootProfile)['accepted'] === true, + 'normalized nonempty data root directly below /afs is accepted' + ); + foreach ($productionProfileKeys as $missingKey) { + $partialProfile = $configuredProfile; + unset($partialProfile[$missingKey]); + readiness_ok( + $rejectProfile($partialProfile), + 'partial production profile missing ' . $missingKey . ' is rejected' + ); + } + $extraProfile = $configuredProfile; + $extraProfile['unreviewed'] = true; + readiness_ok( + $rejectProfile($extraProfile), + 'production profile with an arbitrary field is rejected' + ); + + $defaultProfile = $configuredProfile; + $defaultProfile['profile'] = null; + $defaultProfile['afs_enabled'] = false; + $defaultProfile['external_auth'] = false; + $defaultProfile['local_auth'] = true; + $defaultProfile['local_users_empty'] = false; + readiness_ok( + $rejectProfile($defaultProfile), + 'default local-auth profile cannot activate AFS production mode' + ); + + $profileFailures = array( + 'wrong immutable profile' => array('profile', 'afs-preview-v1'), + 'AFS disabled' => array('afs_enabled', false), + 'non-boolean AFS enablement' => array('afs_enabled', 1), + 'external auth disabled' => array('external_auth', false), + 'local auth enabled' => array('local_auth', true), + 'local users present' => array('local_users_empty', false), + 'settings enabled' => array('settings_enabled', true), + 'embed enabled' => array('embed_enabled', true), + 'direct links enabled' => array('direct_links_enabled', true), + 'raw previews enabled' => array('raw_previews_enabled', true), + 'URL upload enabled' => array('url_upload_enabled', true), + 'non-boolean URL upload setting' => array( + 'url_upload_enabled', 'false' + ), + 'raw root URL' => array('root_url', '/afs'), + 'bare AFS data root' => array('data_root', '/afs'), + 'empty AFS data root suffix' => array('data_root', '/afs/'), + 'outside AFS data root' => array('data_root', '/srv/files'), + 'relative AFS data root' => array('data_root', 'afs/example.test'), + 'trailing-slash AFS data root' => array( + 'data_root', '/afs/example.test/' + ), + 'double-slash AFS data root' => array( + 'data_root', '/afs/example.test//users' + ), + 'dot-segment AFS data root' => array( + 'data_root', '/afs/example.test/./users' + ), + 'parent-segment AFS data root' => array( + 'data_root', '/afs/example.test/../users' + ), + 'backslash AFS data root' => array( + 'data_root', '/afs/example.test\\users' + ), + 'control-bearing AFS data root' => array( + 'data_root', "/afs/example.test/users\nadmin" + ), + 'non-string AFS data root' => array( + 'data_root', array('/afs/example.test') + ), + 'missing manifest digest' => array('asset_manifest_sha256', ''), + 'non-hex manifest digest' => array( + 'asset_manifest_sha256', str_repeat('z', 64) + ), + 'uppercase manifest digest' => array( + 'asset_manifest_sha256', str_repeat('A', 64) + ), + 'non-string manifest digest' => array( + 'asset_manifest_sha256', array(str_repeat('a', 64)) + ), + 'absolute self URL' => array( + 'self_url', 'https://files.example.test/tinyfilemanager.php' + ), + 'protocol-relative self URL' => array( + 'self_url', '//files.example.test/tinyfilemanager.php' + ), + 'non-root-relative self URL' => array( + 'self_url', 'tinyfilemanager.php' + ), + 'query-bearing self URL' => array( + 'self_url', '/tinyfilemanager.php?raw=1' + ), + 'empty request identity' => array('request_identity', ''), + 'unsafe request identity' => array( + 'request_identity', "alice\nadmin" + ), + 'empty factory class' => array('expected_factory_class', ''), + 'unsafe factory class' => array( + 'expected_factory_class', 'Trusted Factory' + ), + 'empty factory identity' => array('expected_factory_id', ''), + 'unsafe factory identity' => array( + 'expected_factory_id', "factory\nother" + ), + 'empty provider class' => array('expected_provider_class', ''), + 'unsafe provider class' => array( + 'expected_provider_class', 'Trusted Provider' + ), + 'empty provider identity' => array('expected_provider_id', ''), + 'unsafe provider identity' => array( + 'expected_provider_id', "provider\0other" + ) + ); + foreach ($profileFailures as $label => $change) { + $candidate = $configuredProfile; + $candidate[$change[0]] = $change[1]; + readiness_ok( + $rejectProfile($candidate), + $label . ' fails production-profile readiness' + ); + } +} + +$builderAvailable = method_exists( + 'AfsProductionReadiness', + 'buildLocalAssetTagsFromManifestFile' +); +readiness_ok( + $builderAvailable, + 'canonical JSON local-asset manifest builder is available' +); +$manifestLoader = readiness_section( + $afsSource, + 'public static function buildLocalAssetTagsFromManifestFile(', + 'public static function validateLocalAsset(', + 'canonical manifest-file loader' +); +$manifestReadPos = strpos($manifestLoader, '@file_get_contents('); +$manifestDigestPos = strpos($manifestLoader, 'hash_equals('); +$manifestDecodePos = strpos($manifestLoader, 'json_decode('); +readiness_ok( + $manifestReadPos !== false && $manifestDigestPos !== false + && $manifestDecodePos !== false + && $manifestReadPos < $manifestDigestPos + && $manifestDigestPos < $manifestDecodePos + && strpos( + $manifestLoader, + "preg_match( '/^[a-f0-9]{64}$/', \$manifestSha256 )" + ) !== false, + 'loader verifies a lowercase digest over exact raw bytes before JSON parsing' +); + +$schemaExpectedAssetKeys = array( + 'css-bootstrap', 'css-dropzone', 'css-font-awesome', + 'css-highlightjs', 'js-ace', 'js-bootstrap', 'js-dropzone', + 'js-jquery', 'js-jquery-datatables', 'js-highlightjs' +); +$schemaAssets = isset($manifestSchema['properties']['assets']) + && is_array($manifestSchema['properties']['assets']) + ? $manifestSchema['properties']['assets'] : array(); +$schemaAssetProperties = isset($schemaAssets['properties']) + && is_array($schemaAssets['properties']) + ? $schemaAssets['properties'] : array(); +$schemaCommon = isset($manifestSchema['$defs']['common']) + && is_array($manifestSchema['$defs']['common']) + ? $manifestSchema['$defs']['common'] : array(); +$schemaCommonProperties = isset($schemaCommon['properties']) + && is_array($schemaCommon['properties']) + ? $schemaCommon['properties'] : array(); +$schemaStyle = isset($manifestSchema['$defs']['style']['allOf'][1]['properties']) + && is_array($manifestSchema['$defs']['style']['allOf'][1]['properties']) + ? $manifestSchema['$defs']['style']['allOf'][1]['properties'] : array(); +readiness_ok( + isset($manifestSchema['properties']['version']['const']) + && $manifestSchema['properties']['version']['const'] === 1 + && isset($schemaAssets['required']) + && readiness_same_keys( + $schemaAssetProperties, + $schemaExpectedAssetKeys + ) + && $schemaAssets['required'] === $schemaExpectedAssetKeys, + 'canonical schema source fixes version 1 and the exact ten logical keys' +); + +$schemaMissingDeferFixture = array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => str_repeat('a', 64), 'license' => 'MIT' +); +readiness_ok( + isset($schemaCommon['required']) + && in_array('defer', $schemaCommon['required'], true) + && !array_key_exists('defer', $schemaMissingDeferFixture), + 'schema source rejects a row fixture with missing defer' +); +$schemaStyleTrueFixture = array('defer' => true); +readiness_ok( + isset($schemaStyle['defer']['const']) + && $schemaStyle['defer']['const'] === false + && $schemaStyleTrueFixture['defer'] !== $schemaStyle['defer']['const'], + 'schema source rejects a style-row fixture with defer true' +); +$schemaNonBooleanDeferFixture = array('defer' => 'false'); +readiness_ok( + isset($schemaCommonProperties['defer']['type']) + && $schemaCommonProperties['defer']['type'] === 'boolean' + && !is_bool($schemaNonBooleanDeferFixture['defer']), + 'schema source rejects a row fixture with non-boolean defer' +); +$schemaUppercaseDigestFixture = strtoupper(str_repeat('a', 64)); +readiness_ok( + isset($schemaCommonProperties['sha256']['pattern']) + && $schemaCommonProperties['sha256']['pattern'] === '^[a-f0-9]{64}$' + && preg_match( + '/' . $schemaCommonProperties['sha256']['pattern'] . '/', + $schemaUppercaseDigestFixture + ) !== 1, + 'schema source rejects an uppercase SHA-256 fixture' +); + +$fixtureBase = sys_get_temp_dir() . '/tfm-afs-readiness-' + . str_replace('.', '-', uniqid('', true)); +$assetRoot = $fixtureBase . '/asset-root'; +$assetDir = $assetRoot . '/assets'; +$cssContents = 'body{}'; +$jsContents = 'void 0;'; +$outsideContents = 'outside();'; +$fixtureReady = @mkdir($assetDir, 0700, true) + && file_put_contents($assetDir . '/app.css', $cssContents) !== false + && file_put_contents($assetDir . '/app.js', $jsContents) !== false + && file_put_contents($assetDir . '/app&theme.css', $cssContents) !== false + && file_put_contents($fixtureBase . '/outside.js', $outsideContents) !== false; +register_shutdown_function('readiness_remove_tree', $fixtureBase); +readiness_ok($fixtureReady, 'typed local-asset fixtures were created'); + +if ($fixtureReady && $builderAvailable) { + $cssHash = hash('sha256', $cssContents); + $jsHash = hash('sha256', $jsContents); + $outsideHash = hash('sha256', $outsideContents); + $requiredAssetKeys = array( + 'css-bootstrap', 'css-dropzone', 'css-font-awesome', + 'css-highlightjs', 'js-ace', 'js-bootstrap', 'js-dropzone', + 'js-jquery', 'js-jquery-datatables', 'js-highlightjs' + ); + $generatedTagKeys = array_merge( + $requiredAssetKeys, + array('pre-jsdelivr', 'pre-cloudflare') + ); + $assets = array( + 'css-bootstrap' => array( + 'type' => 'style', 'path' => 'assets/app.css', + 'sha256' => $cssHash, 'license' => 'MIT', 'defer' => false + ), + 'css-dropzone' => array( + 'type' => 'style', 'path' => 'assets/app.css', + 'sha256' => $cssHash, 'license' => 'MIT', 'defer' => false + ), + 'css-font-awesome' => array( + 'type' => 'style', 'path' => 'assets/app.css', + 'sha256' => $cssHash, 'license' => 'MIT', 'defer' => false + ), + 'css-highlightjs' => array( + 'type' => 'style', 'path' => 'assets/app.css', + 'sha256' => $cssHash, 'license' => 'BSD-3-Clause', + 'defer' => false + ), + 'js-ace' => array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => $jsHash, 'license' => 'BSD-3-Clause', + 'defer' => false + ), + 'js-bootstrap' => array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => $jsHash, 'license' => 'MIT', 'defer' => false + ), + 'js-dropzone' => array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => $jsHash, 'license' => 'MIT', 'defer' => false + ), + 'js-jquery' => array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => $jsHash, 'license' => 'MIT', 'defer' => false + ), + 'js-jquery-datatables' => array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => $jsHash, 'license' => 'MIT', 'defer' => true + ), + 'js-highlightjs' => array( + 'type' => 'script', 'path' => 'assets/app.js', + 'sha256' => $jsHash, 'license' => 'BSD-3-Clause', + 'defer' => false + ) + ); + $artifact = array('version' => 1, 'assets' => $assets); + $artifactCounter = 0; + $writeArtifact = function ($candidateArtifact) use ( + $assetRoot, &$artifactCounter + ) { + $artifactCounter++; + $file = 'asset-manifest-' . $artifactCounter . '.json'; + $path = $assetRoot . '/' . $file; + $json = json_encode($candidateArtifact, JSON_UNESCAPED_SLASHES); + if (!is_string($json) || file_put_contents($path, $json) === false) { + return false; + } + return array( + 'file' => $file, + 'sha256' => hash('sha256', $json) + ); + }; + $buildFile = function ( + $manifestFile, $candidateRoot, $manifestSha256 + ) { + $error = null; + $tags = AfsProductionReadiness::buildLocalAssetTagsFromManifestFile( + $manifestFile, $candidateRoot, $manifestSha256, $error + ); + return array('tags' => $tags, 'error' => $error); + }; + $build = function ($candidateArtifact, $candidateRoot) use ( + $writeArtifact, $buildFile + ) { + $record = $writeArtifact($candidateArtifact); + if (!is_array($record)) { + return array('tags' => false, 'error' => 'fixture write failed'); + } + return $buildFile( + $record['file'], $candidateRoot, $record['sha256'] + ); + }; + $reject = function ($candidateArtifact, $candidateRoot) use ($build) { + $result = $build($candidateArtifact, $candidateRoot); + return $result['tags'] === false + && is_string($result['error']) && $result['error'] !== ''; + }; + + $built = $build($artifact, $assetRoot); + readiness_ok( + is_array($built['tags']) + && readiness_same_keys($built['tags'], $generatedTagKeys), + 'canonical version-1 JSON artifact produces required tags and disabled preconnects' + ); + $canonicalArtifactRecord = $writeArtifact($artifact); + readiness_ok( + is_array($canonicalArtifactRecord) + && isset( + $canonicalArtifactRecord['file'], + $canonicalArtifactRecord['sha256'] + ), + 'digest-pinned canonical manifest fixture was created' + ); + if (is_array($canonicalArtifactRecord)) { + $actualManifestDigest = $canonicalArtifactRecord['sha256']; + $mismatchedManifestDigest = substr($actualManifestDigest, 0, 63) + . (substr($actualManifestDigest, -1) === '0' ? '1' : '0'); + $manifestDigestFailures = array( + 'missing manifest digest' => '', + 'non-hex manifest digest' => str_repeat('z', 64), + 'uppercase manifest digest' => str_repeat('A', 64), + 'mismatched manifest digest' => $mismatchedManifestDigest + ); + foreach ($manifestDigestFailures as $label => $manifestDigest) { + $digestResult = $buildFile( + $canonicalArtifactRecord['file'], + $assetRoot, + $manifestDigest + ); + readiness_ok( + $digestResult['tags'] === false + && is_string($digestResult['error']) + && $digestResult['error'] !== '', + $label . ' fails canonical artifact readiness' + ); + } + } + + $changedRawRecord = $writeArtifact($artifact); + $changedRawReady = is_array($changedRawRecord) + && file_put_contents( + $assetRoot . '/' . $changedRawRecord['file'], + "\n", + FILE_APPEND + ) !== false; + readiness_ok($changedRawReady, 'raw-byte digest-change fixture was created'); + if ($changedRawReady) { + $changedRawResult = $buildFile( + $changedRawRecord['file'], + $assetRoot, + $changedRawRecord['sha256'] + ); + readiness_ok( + $changedRawResult['tags'] === false, + 'any raw manifest byte change invalidates its pinned digest' + ); + } + $expectedTags = array(); + foreach ($assets as $key => $entry) { + $escapedPath = htmlspecialchars( + $entry['path'], ENT_QUOTES, 'UTF-8' + ); + $expectedTags[$key] = $entry['type'] === 'style' + ? '' + : ''; + } + $expectedTags['pre-jsdelivr'] = ''; + $expectedTags['pre-cloudflare'] = ''; + readiness_ok( + $built['tags'] === $expectedTags, + 'builder generates only canonical path-based link and script tags' + ); + readiness_ok( + strpos($built['tags']['js-jquery-datatables'], ' defer') !== false, + 'typed true defer option is preserved on a generated script tag' + ); + readiness_ok( + strpos(implode("\n", $built['tags']), $cssHash) === false + && strpos(implode("\n", $built['tags']), $jsHash) === false, + 'review hashes are verified but are not emitted into generated tags' + ); + readiness_ok( + strpos(implode("\n", $built['tags']), 'MIT') === false + && strpos(implode("\n", $built['tags']), 'BSD-3-Clause') === false, + 'reviewed license metadata is validated but not emitted into tags' + ); + + $escapedArtifact = $artifact; + $escapedArtifact['assets']['css-bootstrap']['path'] = + 'assets/app&theme.css'; + $escapedTags = $build($escapedArtifact, $assetRoot); + readiness_ok( + is_array($escapedTags['tags']) + && strpos( + $escapedTags['tags']['css-bootstrap'], + 'href="assets/app&theme.css"' + ) !== false, + 'generated asset paths are HTML-attribute encoded' + ); + + $uppercaseHash = $artifact; + $uppercaseHash['assets']['js-ace']['sha256'] = strtoupper($jsHash); + readiness_ok( + $reject($uppercaseHash, $assetRoot), + 'uppercase hexadecimal SHA-256 fails canonical lowercase readiness' + ); + + $acceptedLicenses = array( + 'MIT', 'BSD-3-Clause', 'Apache-2.0', 'OFL-1.1' + ); + foreach ($acceptedLicenses as $license) { + $licensedArtifact = $artifact; + $licensedArtifact['assets']['js-ace']['license'] = $license; + readiness_ok( + is_array($build($licensedArtifact, $assetRoot)['tags']), + 'reviewed SPDX license ' . $license . ' is accepted' + ); + } + foreach ($requiredAssetKeys as $assetKey) { + $missingDeferArtifact = $artifact; + unset($missingDeferArtifact['assets'][$assetKey]['defer']); + readiness_ok( + $reject($missingDeferArtifact, $assetRoot), + 'required defer is enforced for manifest row ' . $assetKey + ); + } + + $versionFailures = array( + 'missing version' => null, + 'string version' => '1', + 'future version' => 2 + ); + foreach ($versionFailures as $label => $version) { + $candidate = $artifact; + if ($label === 'missing version') { + unset($candidate['version']); + } else { + $candidate['version'] = $version; + } + readiness_ok( + $reject($candidate, $assetRoot), + $label . ' fails canonical manifest readiness' + ); + } + $extraTopLevel = $artifact; + $extraTopLevel['metadata'] = array('reviewed' => true); + readiness_ok( + $reject($extraTopLevel, $assetRoot), + 'arbitrary top-level manifest field is rejected' + ); + $missingAssets = $artifact; + unset($missingAssets['assets']); + readiness_ok( + $reject($missingAssets, $assetRoot), + 'manifest missing its assets object is rejected' + ); + $invalidAssets = $artifact; + $invalidAssets['assets'] = 'not-an-object'; + readiness_ok( + $reject($invalidAssets, $assetRoot), + 'non-object manifest assets value is rejected' + ); + + $missingKey = $artifact; + unset($missingKey['assets']['js-ace']); + readiness_ok( + $reject($missingKey, $assetRoot), + 'manifest missing an exact required key fails readiness' + ); + $extraKey = $artifact; + $extraKey['assets']['js-extra'] = $assets['js-ace']; + readiness_ok( + $reject($extraKey, $assetRoot), + 'manifest with an extra key fails readiness' + ); + + $rowFailures = array(); + $rowFailures['non-array row'] = ''; + $rowFailures['missing type'] = $assets['js-ace']; + unset($rowFailures['missing type']['type']); + $rowFailures['missing path'] = $assets['js-ace']; + unset($rowFailures['missing path']['path']); + $rowFailures['missing SHA-256'] = $assets['js-ace']; + unset($rowFailures['missing SHA-256']['sha256']); + $rowFailures['missing license'] = $assets['js-ace']; + unset($rowFailures['missing license']['license']); + $rowFailures['empty license'] = $assets['js-ace']; + $rowFailures['empty license']['license'] = ''; + $rowFailures['non-string license'] = $assets['js-ace']; + $rowFailures['non-string license']['license'] = array('MIT'); + $rowFailures['unknown license'] = $assets['js-ace']; + $rowFailures['unknown license']['license'] = 'Unreviewed-Proprietary'; + $rowFailures['markup license'] = $assets['js-ace']; + $rowFailures['markup license']['license'] = ''; + $rowFailures['extra field'] = $assets['js-ace']; + $rowFailures['extra field']['html'] = ''; + $rowFailures['wrong key type'] = $assets['js-ace']; + $rowFailures['wrong key type']['type'] = 'style'; + $rowFailures['style key wrong type'] = $assets['css-bootstrap']; + $rowFailures['style key wrong type']['type'] = 'script'; + $rowFailures['unknown type'] = $assets['js-ace']; + $rowFailures['unknown type']['type'] = 'module'; + $rowFailures['non-string path'] = $assets['js-ace']; + $rowFailures['non-string path']['path'] = array('assets/app.js'); + $rowFailures['non-string SHA-256'] = $assets['js-ace']; + $rowFailures['non-string SHA-256']['sha256'] = array($jsHash); + $rowFailures['short SHA-256'] = $assets['js-ace']; + $rowFailures['short SHA-256']['sha256'] = substr($jsHash, 1); + $rowFailures['non-hex SHA-256'] = $assets['js-ace']; + $rowFailures['non-hex SHA-256']['sha256'] = str_repeat('z', 64); + $rowFailures['mismatched SHA-256'] = $assets['js-ace']; + $rowFailures['mismatched SHA-256']['sha256'] = str_repeat('0', 64); + $rowFailures['style defer option'] = $assets['css-bootstrap']; + $rowFailures['style defer option']['defer'] = true; + $rowFailures['non-boolean defer'] = $assets['js-ace']; + $rowFailures['non-boolean defer']['defer'] = 'true'; + foreach ($rowFailures as $label => $row) { + $candidate = $artifact; + $candidate['assets'][strpos($label, 'style ') === 0 + ? 'css-bootstrap' : 'js-ace'] = $row; + readiness_ok( + $reject($candidate, $assetRoot), + $label . ' fails typed manifest readiness' + ); + } + + $pathFailures = array( + 'HTTP URL' => 'http://example.invalid/app.js', + 'HTTPS URL' => 'https://example.invalid/app.js', + 'protocol-relative URL' => '//example.invalid/app.js', + 'javascript URL' => 'javascript:alert(1)', + 'data URL' => 'data:text/javascript,alert(1)', + 'blob URL' => 'blob:deadbeef', + 'file URL' => 'file:///tmp/app.js', + 'mixed-case scheme' => 'JaVaScRiPt:alert(1)', + 'arbitrary URI scheme' => 'custom+asset:payload', + 'percent-encoded scheme' => 'https%3A%2F%2Fexample.invalid/app.js', + 'percent-encoded network path' => '%2F%2Fexample.invalid/app.js', + 'absolute path' => '/assets/app.js', + 'backslash path' => 'assets\\app.js', + 'parent traversal' => '../outside.js', + 'encoded parent traversal' => '%2e%2e/outside.js', + 'encoded question mark' => 'assets/app.js%3Fmissing', + 'lowercase encoded question mark' => 'assets/app.js%3fmissing', + 'encoded fragment delimiter' => 'assets/app.js%23missing', + 'missing file' => 'assets/missing.js', + 'directory path' => 'assets' + ); + foreach ($pathFailures as $label => $path) { + $candidate = $artifact; + $candidate['assets']['js-ace']['path'] = $path; + if ($label === 'parent traversal' + || $label === 'encoded parent traversal') { + $candidate['assets']['js-ace']['sha256'] = $outsideHash; + } + readiness_ok( + $reject($candidate, $assetRoot), + $label . ' fails typed local-path readiness' + ); + } + readiness_ok( + $reject($artifact, $assetRoot . '/missing'), + 'missing configured asset root fails readiness' + ); + + $manifestPathFailures = array( + 'empty manifest path' => '', + 'absolute manifest path' => '/asset-manifest.json', + 'HTTP manifest URL' => 'http://example.invalid/assets.json', + 'HTTPS manifest URL' => 'https://example.invalid/assets.json', + 'protocol-relative manifest URL' => '//example.invalid/assets.json', + 'manifest parent traversal' => '../asset-manifest.json', + 'manifest dot segment' => './asset-manifest.json', + 'encoded manifest delimiter' => 'asset-manifest%2ejson', + 'query-bearing manifest path' => 'asset-manifest.json?version=1', + 'fragment-bearing manifest path' => 'asset-manifest.json#v1', + 'backslash manifest path' => 'assets\\asset-manifest.json', + 'whitespace manifest path' => ' asset-manifest.json', + 'missing manifest file' => 'missing-manifest.json' + ); + foreach ($manifestPathFailures as $label => $manifestPath) { + $manifestPathResult = $buildFile( + $manifestPath, $assetRoot, str_repeat('a', 64) + ); + readiness_ok( + $manifestPathResult['tags'] === false + && is_string($manifestPathResult['error']) + && $manifestPathResult['error'] !== '', + $label . ' fails canonical manifest-artifact readiness' + ); + } + + $invalidJsonFile = 'invalid-manifest.json'; + $invalidJsonPath = $assetRoot . '/' . $invalidJsonFile; + $invalidJsonRaw = '{"version":1,"assets":'; + $invalidJsonReady = file_put_contents( + $invalidJsonPath, + $invalidJsonRaw + ) !== false; + readiness_ok($invalidJsonReady, 'invalid JSON manifest fixture was created'); + if ($invalidJsonReady) { + $invalidJsonResult = $buildFile( + $invalidJsonFile, + $assetRoot, + hash('sha256', $invalidJsonRaw) + ); + readiness_ok( + $invalidJsonResult['tags'] === false + && is_string($invalidJsonResult['error']) + && $invalidJsonResult['error'] !== '', + 'malformed JSON manifest artifact fails readiness' + ); + } + + $manifestSymlinkFile = 'manifest-link.json'; + $manifestSymlink = $assetRoot . '/' . $manifestSymlinkFile; + $manifestLinkReady = is_array($canonicalArtifactRecord) + && @symlink($canonicalArtifactRecord['file'], $manifestSymlink); + readiness_ok($manifestLinkReady, 'manifest-artifact symlink fixture was created'); + if ($manifestLinkReady) { + $manifestLinkResult = $buildFile( + $manifestSymlinkFile, + $assetRoot, + $canonicalArtifactRecord['sha256'] + ); + readiness_ok( + $manifestLinkResult['tags'] === false, + 'canonical manifest artifact itself cannot be a symlink' + ); + } + + $outsideLink = $assetDir . '/outside-link.js'; + $insideLink = $assetDir . '/inside-link.js'; + $insideDirectoryLink = $assetRoot . '/linked-assets'; + $symlinksReady = @symlink('../../outside.js', $outsideLink) + && @symlink('app.js', $insideLink) + && @symlink('assets', $insideDirectoryLink); + readiness_ok($symlinksReady, 'asset-symlink fixtures were created'); + if ($symlinksReady) { + $outsideLinkManifest = $artifact; + $outsideLinkManifest['assets']['js-ace']['path'] = + 'assets/outside-link.js'; + $outsideLinkManifest['assets']['js-ace']['sha256'] = $outsideHash; + readiness_ok( + $reject($outsideLinkManifest, $assetRoot), + 'out-of-root asset symlink fails readiness' + ); + $insideLinkManifest = $artifact; + $insideLinkManifest['assets']['js-ace']['path'] = + 'assets/inside-link.js'; + readiness_ok( + $reject($insideLinkManifest, $assetRoot), + 'in-root asset symlink also fails reviewed-manifest readiness' + ); + $insideDirectoryLinkManifest = $artifact; + $insideDirectoryLinkManifest['assets']['js-ace']['path'] = + 'linked-assets/app.js'; + readiness_ok( + $reject($insideDirectoryLinkManifest, $assetRoot), + 'an in-root intermediate directory symlink fails readiness' + ); + } +} + +echo "\n" . $readinessPasses . " readiness assertions passed"; +if (!empty($readinessFailures)) { + echo ", " . count($readinessFailures) . " failed\n"; + exit(1); +} +echo ", 0 failed\n"; +exit(0); diff --git a/tests/afs_regression.php b/tests/afs_regression.php new file mode 100644 index 00000000..ba4232eb --- /dev/null +++ b/tests/afs_regression.php @@ -0,0 +1,682 @@ +commands[] = $arguments; + $this->lastFsStatus = 0; + return empty($this->responses) ? '' : array_shift($this->responses); + } +} + +final class AfsFilesystemDouble extends Afs +{ + public function __construct() + { + // configureFilesystem() supplies an offline device model. + } + + public function configureFilesystem($device, $startCwd) + { + $this->afsAvailable = true; + $this->afsStat = array('dev' => $device); + $this->startCWD = $startCwd; + } + + public function setTestPath($path) + { + $this->path = $path; + } + + public function copyItemForTest($source, $target) + { + return $this->copyItem($source, $target); + } + + public function configureCopyRequest($originPath, $destinationPath, $selectedItems) + { + $this->originPath = $originPath; + $this->path = $destinationPath; + $this->selectedItems = $selectedItems; + } +} + +final class AfsDataPlaneTestDouble extends AfsDataPlane +{ + private $testVolumeMounts = array(); + + public function __construct() + { + // configureDataPlane() supplies an offline AFS model. + } + + public function configureDataPlane($root, $startCwd) + { + $stat = stat($root); + $this->afsAvailable = true; + $this->afsStat = array('dev' => $stat['dev']); + $this->startCWD = $startCwd; + return $this->initializeDataPlane($root); + } + + public function addVolumeMount($path, $target) + { + $this->testVolumeMounts[$path] = $target; + unset($this->volumeMountCache[$path]); + } + + public function addKernelMount($path) + { + $this->kernelMountPoints[$path] = true; + } + + protected function loadKernelMountPoints() + { + return array(); + } + + protected function probeAfsIdentity($path, $nofollow = false, $fresh = false) + { + if (!is_array(@lstat($path))) { + return false; + } + $volume = '100'; + foreach ($this->testVolumeMounts as $mountPath => $target) { + if ($path === $mountPath || strpos($path, $mountPath . '/') === 0) { + $volume = '200'; + } + } + return array('fid' => $volume . '.1.1', 'volume' => $volume); + } + + protected function probeAfsVolumeMountPoint($path) + { + return array_key_exists($path, $this->testVolumeMounts) + ? $this->testVolumeMounts[$path] : false; + } +} + +final class AfsDataPlaneProbeDouble extends AfsDataPlane +{ + private $testResponses = array(); + + public function __construct() + { + // Probe parser tests do not need a live constructor. + } + + public function queueResponse($status, $output) + { + $this->testResponses[] = array($status, $output); + } + + public function probeMountForTest($path) + { + return $this->probeAfsVolumeMountPoint($path); + } + + public function probeIdentityForTest($path, $nofollow = false) + { + return $this->probeAfsIdentity($path, $nofollow, true); + } + + protected function runFs($arguments) + { + if (empty($this->testResponses)) { + $this->lastFsStatus = 127; + return false; + } + list($this->lastFsStatus, $output) = array_shift($this->testResponses); + return $output; + } +} + +$tests = 0; + +function check($condition, $message) +{ + global $tests; + $tests++; + if (!$condition) { + fwrite(STDERR, "not ok $tests - $message\n"); + exit(1); + } + echo "ok $tests - $message\n"; +} + +function remove_test_tree($path) +{ + if (is_link($path) || is_file($path) + || (file_exists($path) && !is_dir($path))) { + @unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + foreach (scandir($path) as $entry) { + if ($entry !== '.' && $entry !== '..') { + remove_test_tree($path . '/' . $entry); + } + } + @rmdir($path); +} + +$aclOutput = "Access list for /afs/example is\n" + . "Normal rights:\n" + . " system:anyuser rl\n" + . " alice rlidwkaABCDEFGH\n" + . " auxiliary AD\n" + . "Negative rights:\n" + . " blocked lkAH\n"; + +$afs = new AfsTestDouble(); +check($afs->get_returnToURI() === '', + 'legacy AFS return URI fails closed until FM_SELF_URL is defined'); +$afs->path = '/afs/example.test/users/alice/My Folder'; +$afs->sid = 'fixed-session-id'; +$_SERVER['HTTP_HOST'] = 'attacker.example'; +$_SERVER['PHP_SELF'] = '//attacker.example/redirect'; +define('FM_SELF_URL', '/tinyfilemanager.php'); +check( + $afs->get_returnToURI() + === '/tinyfilemanager.php?path=%2Fafs%2Fexample.test%2Fusers%2Falice%2FMy+Folder' + . '&finishid=fixed-session-id', + 'legacy AFS return URI uses FM_SELF_URL instead of request host or script data' +); +$acl = $afs->parseAclOutput($aclOutput); +check($acl['inherited'] === false, 'marks a normal ACL as explicit'); +check(isset($acl['normal']['alice']), 'parses a normal ACL principal'); +check($acl['normal']['alice']['l'] && $acl['normal']['alice']['r'] + && $acl['normal']['alice']['w'] && $acl['normal']['alice']['i'] + && $acl['normal']['alice']['d'] && $acl['normal']['alice']['k'] + && $acl['normal']['alice']['a'] && $acl['normal']['alice']['A'] + && $acl['normal']['alice']['B'] && $acl['normal']['alice']['C'] + && $acl['normal']['alice']['D'] && $acl['normal']['alice']['E'] + && $acl['normal']['alice']['F'] && $acl['normal']['alice']['G'] + && $acl['normal']['alice']['H'], + 'parses seven standard and eight AuriStor auxiliary rights'); +check($acl['normal']['system:anyuser']['l'] + && $acl['normal']['system:anyuser']['r'] + && !$acl['normal']['system:anyuser']['w'], 'tracks unset normal rights'); +check($acl['negative']['blocked']['l'] && $acl['negative']['blocked']['k'] + && $acl['negative']['blocked']['A'] && $acl['negative']['blocked']['H'] + && !$acl['negative']['blocked']['a'], 'parses negative ACL rights'); +check($acl['normal']['auxiliary']['A'] && $acl['normal']['auxiliary']['D'] + && $acl['normal']['auxiliary']['a'] === false + && $acl['normal']['auxiliary']['d'] === false, + 'auxiliary rights remain case-distinct from admin and delete'); + +$inheritedOutput = str_replace('Access list for', 'Access list (inherited) for', $aclOutput); +$inheritedAcl = $afs->parseAclOutput($inheritedOutput); +check($inheritedAcl['inherited'] === true, + 'detects an inherited AuriStor ACL without materializing it'); +check($afs->parseAclOutput("unexpected output\n") === false, + 'rejects listacl output without the expected headers'); +$unknownRightOutput = str_replace('system:anyuser rl', 'system:anyuser rlZ', $aclOutput); +check($afs->parseAclOutput($unknownRightOutput) === false, + 'rejects an unknown ACL right instead of dropping it'); + +$maxAclFixture = file_get_contents( + __DIR__ . '/fixtures/auristor-listacl-with-volume-acl.txt'); +check($maxAclFixture !== false, 'loads the AuriStor Volume ACL fixture'); +check($afs->parseAclOutput($maxAclFixture) === false, + 'fails closed instead of merging a Volume ACL into the object ACL'); +$inheritedMaxAclFixture = preg_replace( + '/^Access list for /', 'Access list (inherited) for ', $maxAclFixture); +check($afs->parseAclOutput($inheritedMaxAclFixture) === false, + 'fails closed when an inherited object ACL is followed by a Volume ACL'); +check($afs->parseAclOutput($aclOutput . $aclOutput) === false, + 'rejects a second object ACL header in one parser invocation'); +$repeatedNormal = str_replace( + "Negative rights:\n", "Normal rights:\n", $aclOutput); +check($afs->parseAclOutput($repeatedNormal) === false, + 'rejects a repeated Normal rights section'); +$repeatedNegative = $aclOutput . "Negative rights:\n another r\n"; +check($afs->parseAclOutput($repeatedNegative) === false, + 'rejects a repeated Negative rights section'); + +$nestedAclPost = array(); +parse_str( + 'normal[user%40cell.example][l]=1&normal[user%20name][r]=1', + $nestedAclPost); +check(isset($nestedAclPost['normal']['user@cell.example']['l']), + 'PHP preserves a dot in a nested ACL principal key'); +check(isset($nestedAclPost['normal']['user name']['r']), + 'PHP preserves a space in a nested ACL principal key'); + +$afs->responses[] = $aclOutput; +$readAcl = $afs->readAcl('/afs/example'); +check($readAcl === $acl, 'readAcl returns the parsed ACL structure'); +check($afs->commands[0] === array('listacl', '/afs/example'), + 'readAcl invokes fs listacl with an argument vector'); + +$afs->responses[] = $maxAclFixture; +check($afs->readAcl('/afs/example') === false + && strpos($afs->errorMsg, 'Unable to parse') !== false, + 'readAcl reports a fail-closed Volume ACL parse result'); + +$afs->responses[] = "fs: permission denied\n"; +check($afs->readAcl('/afs/example') === false, + 'readAcl rejects fs-reported failures'); + +$afs->responses[] = "Callers access to /afs/example is rlidwkaABCDEFGH\n"; +check($afs->getACLAccess('/afs/example') === 'rlidwkaABCDEFGH', + 'getcalleraccess preserves standard and auxiliary right case'); +check($afs->lookupPriv === 1 && $afs->readPriv === 1 + && $afs->writePriv === 1 && $afs->insertPriv === 1 + && $afs->deletePriv === 1 && $afs->lockPriv === 1 + && $afs->adminPriv === 1, 'getcalleraccess maps all privilege flags'); + +$afs->responses[] = "unexpected output\n"; +check($afs->getACLAccess('/afs/example') === '', + 'malformed getcalleraccess output fails closed'); +check($afs->lookupPriv === 0 && $afs->readPriv === 0 + && $afs->writePriv === 0 && $afs->insertPriv === 0 + && $afs->deletePriv === 0 && $afs->lockPriv === 0 + && $afs->adminPriv === 0, 'failed parsing clears stale privilege flags'); + +$afs->responses[] = ''; +check($afs->changeAcl('user;touch /tmp/not-run', 'rl', '/afs/a path') === true, + 'normal ACL changes accept shell-sensitive names as data'); +check($afs->commands[count($afs->commands) - 1] + === array('sa', '/afs/a path', 'user;touch /tmp/not-run', 'rl'), + 'normal ACL command remains a structured argument vector'); + +$afs->responses[] = ''; +check($afs->changeAcl('blocked', 'lkAH', '/afs/example', false, true) === true, + 'negative ACL changes are supported'); +check($afs->commands[count($afs->commands) - 1] + === array('sa', '-negative', '/afs/example', 'blocked', 'lkAH'), + 'negative ACL command uses the explicit -negative argument'); +check($afs->changeAcl('alice', 'rl;bad', '/afs/example') === false, + 'invalid ACL rights fail before invoking fs'); + +$afs->responses[] = ''; +check($afs->changeAclEntries( + array('alice' => 'rl', 'system:anyuser' => 'l'), '/afs/example') === true, + 'multiple ACL entries are changed in one batch'); +check($afs->commands[count($afs->commands) - 1] + === array('sa', '/afs/example', 'alice', 'rl', 'system:anyuser', 'l'), + 'batched ACL entries share one fs setacl invocation'); + +$probe = new AfsDataPlaneProbeDouble(); +$probe->queueResponse( + 0, "'/afs/example/child' is a mount point for volume '#child.volume'"); +check($probe->probeMountForTest('/afs/example/child') === '#child.volume', + 'checked fs lsmount output identifies an AFS volume mount point'); +$probe->queueResponse( + 1, "'/afs/example/ordinary' is not a mount point."); +check($probe->probeMountForTest('/afs/example/ordinary') === false, + 'checked fs lsmount error status identifies an ordinary directory'); +$probe->queueResponse(1, 'fs: permission denied'); +check($probe->probeMountForTest('/afs/example/unknown') === null, + 'an unrecognized lsmount failure remains fail-closed'); +$probe->queueResponse( + 0, 'File /afs/example/file (536870918.20404.20997) contained in volume 536870918'); +$identity = $probe->probeIdentityForTest('/afs/example/file'); +check($identity === array( + 'fid' => '536870918.20404.20997', + 'volume' => '536870918'), + 'checked fs getfid output records the resolved AFS identity'); +$probe->queueResponse(1, 'fs: path is not in AFS'); +check($probe->probeIdentityForTest('/tmp/not-afs') === false, + 'failed fs getfid classification rejects a non-AFS path'); + +$tempRoot = sys_get_temp_dir() . '/tinyfm-afs-test-' . bin2hex(random_bytes(8)); +check(mkdir($tempRoot, 0700), 'creates an isolated test directory'); +$originalCwd = getcwd(); + +try { + $inside = $tempRoot . '/inside'; + check(mkdir($inside, 0700), 'creates an in-device path'); + $device = stat($inside)['dev']; + $fsAfs = new AfsFilesystemDouble(); + $fsAfs->configureFilesystem($device, $originalCwd); + + check($fsAfs->makePathAFSlocal($inside) === true, + 'same-device directory passes the final chdir/stat guard'); + chdir($originalCwd); + check($fsAfs->setPath($inside) === true && $fsAfs->path === $inside, + 'setPath accepts a same-device path'); + + $fsAfs->configureFilesystem($device + 1, $originalCwd); + check($fsAfs->makePathAFSlocal($inside) === false, + 'different-device directory fails the final guard'); + check(getcwd() === $originalCwd, 'failed device guard restores the cwd'); + check($fsAfs->setPath($inside) === false && $fsAfs->path === '', + 'setPath rejects a different-device path'); + + $source = $tempRoot . '/source.bin'; + $destination = $tempRoot . '/destination.bin'; + $payload = "\x00AFS\n" . random_bytes(2048); + file_put_contents($source, $payload); + $fsAfs->configureFilesystem($device, $originalCwd); + check($fsAfs->copy($source, $destination) === true, + 'handle-checked copy succeeds on the modeled AFS device'); + check(file_get_contents($destination) === $payload, + 'handle-checked copy preserves binary content'); + check($fsAfs->copy($source, $destination) === false, + 'handle-checked copy refuses to overwrite an existing destination'); + + $outsideDestination = $tempRoot . '/wrong-device.bin'; + $fsAfs->configureFilesystem($device + 1, $originalCwd); + check($fsAfs->copy($source, $outsideDestination) === false + && !file_exists($outsideDestination), + 'source handle device mismatch creates no destination'); + + $fsAfs->configureFilesystem($device, $originalCwd); + $fsAfs->setTestPath($source); + ob_start(); + $readResult = $fsAfs->readfile(); + $readPayload = ob_get_clean(); + check($readResult === true && $readPayload === $payload, + 'handle-checked read emits exact binary content'); + + if (function_exists('symlink')) { + $broken = $tempRoot . '/broken-link'; + @symlink('missing-target', $broken); + check($fsAfs->linkSafeFileExists($broken) === true, + 'lstat recognizes a broken symlink without following it'); + + $outside = $tempRoot . '/outside'; + $treeSource = $tempRoot . '/tree-source'; + $treeTarget = $tempRoot . '/tree-target'; + check(mkdir($outside, 0700) && mkdir($treeSource, 0700), + 'creates isolated source and outside directories'); + check(stat($outside)['dev'] === $device, + 'outside symlink target is on the modeled AFS device'); + file_put_contents($outside . '/sentinel.txt', 'outside-data'); + check(symlink($outside, $treeSource . '/outside-link'), + 'creates a directory symlink to an outside same-device tree'); + check($fsAfs->copy_dirs($treeSource, $treeTarget) === true, + 'recursive copy completes with a nested directory symlink'); + check(is_link($treeTarget . '/outside-link') + && readlink($treeTarget . '/outside-link') === $outside, + 'recursive copy reproduces the directory symlink without traversing it'); + check(file_get_contents($outside . '/sentinel.txt') === 'outside-data', + 'recursive copy leaves the outside sentinel unchanged'); + + $directTarget = $tempRoot . '/direct-symlink-target'; + check($fsAfs->copy_dirs($treeSource . '/outside-link', $directTarget) === false + && !file_exists($directTarget) && !is_link($directTarget), + 'copy_dirs rejects a directory symlink passed as its top-level source'); + + $copiedTopLink = $tempRoot . '/copied-top-link'; + check($fsAfs->copyItemForTest( + $treeSource . '/outside-link', $copiedTopLink) === true + && is_link($copiedTopLink) + && readlink($copiedTopLink) === $outside, + 'copy dispatcher handles a top-level directory symlink as a link'); + + $copiedBroken = $tempRoot . '/copied-broken-link'; + check($fsAfs->copyItemForTest($broken, $copiedBroken) === true + && is_link($copiedBroken) + && readlink($copiedBroken) === 'missing-target', + 'copy dispatcher preserves a broken symlink'); + + $requestTarget = $tempRoot . '/copy-request-target'; + check(mkdir($requestTarget, 0700), + 'creates a destination for the copyFiles request path'); + $fsAfs->configureCopyRequest( + $treeSource, $requestTarget, 'outside-link'); + check($fsAfs->copyFiles() === true + && is_link($requestTarget . '/outside-link') + && readlink($requestTarget . '/outside-link') === $outside, + 'copyFiles dispatches a directory symlink without traversing it'); + } + + if (function_exists('posix_mkfifo')) { + $fifo = $tempRoot . '/source.fifo'; + $fifoTarget = $tempRoot . '/copied.fifo'; + check(posix_mkfifo($fifo, 0600), 'creates an unsupported special file'); + check($fsAfs->copyItemForTest($fifo, $fifoTarget) === false + && !file_exists($fifoTarget), + 'copy dispatcher fails closed for unsupported special files'); + } + + $guardRoot = $tempRoot . '/guard-root'; + $guardOutside = $tempRoot . '/guard-outside'; + check(mkdir($guardRoot, 0700) && mkdir($guardOutside, 0700), + 'creates rooted and outside trees for the data-plane facade'); + file_put_contents($guardOutside . '/sentinel.txt', 'outside-sentinel'); + + $dataPlane = new AfsDataPlaneTestDouble(); + check($dataPlane->configureDataPlane($guardRoot, $originalCwd) === true, + 'initializes the offline pathname-policy AFS data-plane preview'); + check($dataPlane instanceof AfsDataPlaneProvider, + 'pathname preview implements the reusable provider contract'); + check($dataPlane->isProductionReady() === false + && $dataPlane->getSecurityBoundary() === 'pathname-preview', + 'pathname preview cannot satisfy the production descriptor boundary'); + check(AfsDataPlaneProvider::SECURITY_BOUNDARY_DESCRIPTOR_BENEATH_V1 + === 'descriptor-beneath-v1', + 'provider contract names the required descriptor-beneath boundary'); + check($dataPlane->getDataRoot() === realpath($guardRoot), + 'pins the data-plane boundary to the resolved configured root'); + check($dataPlane->resolveExistingPath($guardOutside) === false, + 'rejects an existing path outside the configured root'); + check($dataPlane->resolveExistingPath( + $guardRoot . '/../guard-outside/sentinel.txt') === false, + 'rejects dot-segment traversal before filesystem access'); + + if (function_exists('symlink')) { + $leafLink = $guardRoot . '/outside-file-link'; + $parentLink = $guardRoot . '/outside-dir-link'; + check(symlink($guardOutside . '/sentinel.txt', $leafLink) + && symlink($guardOutside, $parentLink), + 'creates final and intermediate POSIX symlink escape fixtures'); + check($dataPlane->resolveExistingPath($leafLink) === false, + 'rejects a final POSIX symlink instead of following it'); + check($dataPlane->resolveExistingPath( + $parentLink . '/sentinel.txt') === false, + 'rejects an intermediate POSIX symlink instead of following it'); + check($dataPlane->writeFile($leafLink, 'changed') === false + && file_get_contents($guardOutside . '/sentinel.txt') + === 'outside-sentinel', + 'guarded writes leave a same-device outside symlink target unchanged'); + $listed = $dataPlane->listDirectory($guardRoot); + $linkInfo = $dataPlane->inspectPath($leafLink, true); + check(is_array($listed) && in_array('outside-file-link', $listed, true) + && is_array($linkInfo) && $linkInfo['type'] === 'link' + && $linkInfo['link_target'] === $guardOutside . '/sentinel.txt', + 'listing exposes a final symlink only as no-follow object metadata'); + $renamedLink = $guardRoot . '/renamed-outside-file-link'; + check($dataPlane->renamePath($leafLink, $renamedLink) === true + && is_link($renamedLink) + && file_get_contents($guardOutside . '/sentinel.txt') + === 'outside-sentinel', + 'rename moves a verified symlink object without traversing it'); + check($dataPlane->removePath($renamedLink) === true + && !is_link($renamedLink) + && file_get_contents($guardOutside . '/sentinel.txt') + === 'outside-sentinel', + 'delete unlinks a verified symlink object without touching its target'); + + $linkTree = $guardRoot . '/link-tree'; + check(mkdir($linkTree, 0700) + && symlink($guardOutside, $linkTree . '/outside-link'), + 'creates a recursive-delete tree containing an outside symlink'); + check($dataPlane->removePath($linkTree) === true + && !file_exists($linkTree) + && file_get_contents($guardOutside . '/sentinel.txt') + === 'outside-sentinel', + 'recursive delete unlinks nested symlinks without following them'); + } + + $kernelMount = $guardRoot . '/kernel-mount'; + check(mkdir($kernelMount, 0700), 'creates a modeled nested kernel mount'); + $dataPlane->addKernelMount(realpath($kernelMount)); + check($dataPlane->resolveExistingPath($kernelMount) === false, + 'rejects a nested kernel mount without using st_dev as a volume model'); + + $childVolume = $guardRoot . '/child-volume'; + check(mkdir($childVolume, 0700), 'creates a modeled child AFS volume root'); + $dataPlane->addVolumeMount(realpath($childVolume), '#child.volume'); + $childWork = $childVolume . '/work'; + check(mkdir($childWork, 0700), 'creates a working directory in the child volume'); + check($dataPlane->resolveExistingPath($childWork, 'dir') + === realpath($childWork), + 'allows logical navigation beneath a classified child AFS volume'); + $crossed = $dataPlane->getCrossedVolumeMounts(); + check(isset($crossed[realpath($childVolume)]) + && $crossed[realpath($childVolume)]['target'] === '#child.volume', + 'records the crossed AFS volume target and resolved identity'); + + $insideFile = $guardRoot . '/inside.txt'; + check($dataPlane->createFile($insideFile) === true, + 'exclusively creates a regular file inside the guarded root'); + check($dataPlane->createFile($insideFile) === false, + 'exclusive creation refuses an existing destination'); + $binary = "\x00guarded\n" . random_bytes(64); + check($dataPlane->writeFile($insideFile, $binary) === true, + 'writes an existing confined file through a validated handle'); + check($dataPlane->readContents($insideFile) === $binary, + 'reads exact binary bytes through the same rooted facade'); + check(is_string($dataPlane->detectMimeType($insideFile)) + && $dataPlane->detectMimeType($insideFile) !== '', + 'MIME sampling is provider-owned and returns a checked string'); + + $nestedDirectory = $guardRoot . '/new/path/tree'; + check($dataPlane->makeDirectory($nestedDirectory, true) === true + && is_dir($nestedDirectory), + 'creates and post-validates each missing directory component'); + + $importSource = $tempRoot . '/import-source.bin'; + $importTarget = $nestedDirectory . '/imported.bin'; + file_put_contents($importSource, 'first-'); + check($dataPlane->importFile( + $importSource, $importTarget, false, false) === true, + 'imports a local upload-style payload into a guarded target'); + file_put_contents($importSource, 'second'); + check($dataPlane->importFile( + $importSource, $importTarget, true, true) === true + && file_get_contents($importTarget) === 'first-second', + 'appends a chunk payload through a post-validated AFS handle'); + + $copyTarget = $guardRoot . '/inside-copy.txt'; + check($dataPlane->copyPath($insideFile, $copyTarget, false) === true + && file_get_contents($copyTarget) === $binary, + 'copies a regular file without falling back to PHP copy'); + $renamedTarget = $guardRoot . '/inside-renamed.txt'; + check($dataPlane->renamePath($copyTarget, $renamedTarget) === true + && !file_exists($copyTarget) && is_file($renamedTarget), + 'renames and post-validates a confined regular file'); + + $childFile = $childWork . '/child.txt'; + file_put_contents($childFile, 'child-data'); + $childCopy = $childWork . '/child-copy.txt'; + check($dataPlane->copyPath($childFile, $childCopy, false) === true + && file_get_contents($childCopy) === 'child-data', + 'permits an operation explicitly started inside a child AFS volume'); + check($dataPlane->removePath($childCopy) === true + && !file_exists($childCopy), + 'permits deletion of a regular object inside a child AFS volume'); + + $recursiveSource = $guardRoot . '/recursive-source'; + $recursiveMount = $recursiveSource . '/nested-volume'; + check(mkdir($recursiveSource, 0700) + && mkdir($recursiveMount, 0700), + 'creates a recursive child-volume boundary fixture'); + file_put_contents($recursiveSource . '/ordinary.txt', 'ordinary'); + file_put_contents($recursiveMount . '/volume-sentinel.txt', 'volume-data'); + $dataPlane->addVolumeMount(realpath($recursiveMount), '#nested.volume'); + $recursiveTarget = $guardRoot . '/recursive-copy'; + check($dataPlane->copyPath( + $recursiveSource, $recursiveTarget, false) === false + && !file_exists($recursiveTarget), + 'recursive copy stops before entering a child AFS volume mount'); + check($dataPlane->removePath($recursiveSource) === false + && file_get_contents($recursiveMount . '/volume-sentinel.txt') + === 'volume-data', + 'recursive delete preflights and leaves a child-volume sentinel intact'); + + $searchRootFile = $guardRoot . '/search-hit.txt'; + file_put_contents($searchRootFile, 'search'); + file_put_contents($childWork . '/search-hit-child.txt', 'search-child'); + $searchResults = $dataPlane->searchFiles($guardRoot, 'search-hit'); + check(is_array($searchResults) && count($searchResults) === 1 + && $searchResults[0]['name'] === 'search-hit.txt', + 'recursive search stays in its starting volume and stops at child mounts'); + + check($dataPlane->archivesSupported() === false, + 'archive mutation is explicitly unavailable in guarded AFS mode'); + check(file_get_contents($guardOutside . '/sentinel.txt') + === 'outside-sentinel', + 'all facade operations leave the outside escape sentinel unchanged'); + + check($fsAfs->escape_js("a'b\\c\r\n") === "a\\'b\\\\c\\r\\n", + 'JavaScript escaping covers quote, slash, CR, and LF'); + + $requiredMethods = array('copy', 'copy_dirs', 'deleteFiles', 'removeFolder', + 'readfile', 'changeAcl', 'readAcl', 'getACLAccess', 'makePathAFSlocal'); + $reflection = new ReflectionClass('Afs'); + foreach ($requiredMethods as $method) { + check($reflection->hasMethod($method), "retains Afs::$method"); + } + $dataReflection = new ReflectionClass('AfsDataPlane'); + foreach (array('initializeDataPlane', 'resolveExistingPath', 'inspectPath', + 'openRead', 'readContents', 'detectMimeType', 'writeFile', 'createFile', + 'importFile', 'makeDirectory', 'copyPath', 'renamePath', 'removePath', + 'listDirectory', 'searchFiles', 'readAcl', 'changeAclEntries', + 'getACLAccess', 'getSecurityBoundary') + as $method) { + check($dataReflection->hasMethod($method), + "retains AfsDataPlane::$method"); + } +} finally { + @chdir($originalCwd); + remove_test_tree($tempRoot); +} + +$urlUploadDisabledProfile = array( + 'profile' => 'afs-descriptor-v1', + 'afs_enabled' => true, + 'external_auth' => true, + 'request_identity' => 'alice@example.test', + 'local_auth' => false, + 'local_users_empty' => true, + 'settings_enabled' => false, + 'embed_enabled' => false, + 'direct_links_enabled' => false, + 'raw_previews_enabled' => false, + 'url_upload_enabled' => false, + 'root_url' => '', + 'self_url' => '/tinyfilemanager.php', + 'data_root' => '/afs/example.test/users/alice', + 'asset_manifest_sha256' => str_repeat('a', 64), + 'expected_factory_class' => 'TrustedAfsFactory', + 'expected_factory_id' => 'trusted-factory-v1', + 'expected_provider_class' => 'TrustedAfsProvider', + 'expected_provider_id' => 'trusted-provider-v1' +); +$profileError = null; +check(AfsProductionReadiness::validateProductionProfile( + $urlUploadDisabledProfile, $profileError) === true, + 'production profile accepts URL upload only when literally false'); +$urlUploadEnabledProfile = $urlUploadDisabledProfile; +$urlUploadEnabledProfile['url_upload_enabled'] = true; +$profileError = null; +check(AfsProductionReadiness::validateProductionProfile( + $urlUploadEnabledProfile, $profileError) === false + && is_string($profileError) + && strpos($profileError, 'url_upload_enabled') !== false, + 'production profile rejects enabled URL upload'); + +echo "1..$tests\n"; diff --git a/tests/afs_static.php b/tests/afs_static.php new file mode 100644 index 00000000..bc1e78ac --- /dev/null +++ b/tests/afs_static.php @@ -0,0 +1,1927 @@ + $start, $label . ' end marker follows its start marker'); + + if ($start === false || $end === false || $end <= $start) { + return ''; + } + + return substr($source, $start, $end - $start); +} + +function afs_test_contains($haystack, $needle, $message) +{ + afs_test_ok(strpos($haystack, $needle) !== false, $message); +} + +function afs_test_not_contains($haystack, $needle, $message) +{ + afs_test_ok(strpos($haystack, $needle) === false, $message); +} + +echo "AFS static integration contract\n"; + +// The side-effect-free provider contract is available to config.php, while the +// runtime AFS helper remains an explicit post-config opt-in/profile dependency. +$defaultPos = strpos($manager, '$afsSupport = false;'); +$contractPos = strpos( + $manager, + "require_once __DIR__ . '/afs_contract.php';" +); +$configPos = strpos($manager, '@include($config_file);'); +$urlUploadDefaultPos = strpos($manager, '$url_upload_enabled = true;'); +$guardPos = strpos( + $manager, + "if (\$afsSupport || defined('AFS_PRODUCTION_PROFILE')) {" +); +$requirePos = strpos($manager, "require_once __DIR__ . '/afs.php';"); + +afs_test_ok($defaultPos !== false, 'AFS support defaults to disabled'); +afs_test_ok($contractPos !== false, 'side-effect-free AFS contract is loaded'); +afs_test_ok($configPos !== false, 'external config.php is included'); +afs_test_ok( + $urlUploadDefaultPos !== false && $configPos !== false + && $urlUploadDefaultPos < $configPos, + 'URL upload defaults enabled for non-AFS before config.php overrides it' +); +afs_test_ok( + $guardPos !== false, + 'AFS helper load is conditional on opt-in or the immutable profile' +); +afs_test_ok($requirePos !== false, 'AFS dependency uses an __DIR__-anchored path'); +afs_test_ok( + $defaultPos !== false && $contractPos !== false && $configPos !== false + && $defaultPos < $contractPos && $contractPos < $configPos, + 'provider contract is loaded before config.php constructs its factory' +); +afs_test_ok( + $configPos !== false && $guardPos !== false && $requirePos !== false + && $configPos < $guardPos && $guardPos < $requirePos, + 'config.php resolves the AFS opt-in/profile before afs.php is required' +); +afs_test_contains( + $afs, + "require_once __DIR__ . '/afs_contract.php';", + 'standalone afs.php loads the shared provider contract' +); +$contractGate = afs_test_section( + $manager, + "if ((\$afsSupport || defined('AFS_PRODUCTION_PROFILE'))", + "if (\$afsSupport || defined('AFS_PRODUCTION_PROFILE')) {", + 'packaged AFS contract readiness gate' +); +afs_test_contains( + $contractGate, + "!interface_exists('AfsDataPlaneProviderFactory', false)", + 'AFS activation requires the packaged provider contract' +); +afs_test_contains( + $contractGate, + 'AFS production requires the packaged provider contract.', + 'missing packaged provider contract fails readiness' +); +afs_test_not_contains( + $contract, + 'extension_loaded(', + 'provider contract has no extension-load side effects' +); +afs_test_not_contains( + $contract, + 'exit(', + 'provider contract cannot terminate config loading' +); + +// A production provider must advertise both readiness and the exact reviewed +// descriptor boundary. The bundled pathname model remains an offline preview. +$factoryInterface = afs_test_section( + $contract, + 'interface AfsDataPlaneProviderFactory', + "interface AfsDataPlaneProvider\n", + 'AFS provider-factory interface' +); +afs_test_contains( + $factoryInterface, + 'public function getFactoryIdentity();', + 'provider factory declares its reviewed identity' +); +afs_test_contains( + $factoryInterface, + 'public function createProvider( $root, $requestIdentity );', + 'provider factory binds root and request identity at creation' +); + +$providerInterface = afs_test_section( + $contract . "\n/* END AFS CONTRACT */\n", + "interface AfsDataPlaneProvider\n", + '/* END AFS CONTRACT */', + 'AFS provider interface' +); +afs_test_contains( + $providerInterface, + 'SECURITY_BOUNDARY_DESCRIPTOR_BENEATH_V1', + 'provider interface names the reviewed descriptor boundary token' +); +afs_test_contains( + $providerInterface, + "'descriptor-beneath-v1'", + 'provider boundary token has the expected versioned value' +); +$providerMethods = array( + 'initializeDataPlane', 'isProductionReady', 'getReadinessFailure', + 'getSecurityBoundary', 'getProviderIdentity', 'getCredentialIdentity', + 'resolveExistingPath', 'resolveWritePath', + 'inspectPath', 'listDirectory', 'searchFiles', 'openRead', + 'readContents', 'detectMimeType', 'createFile', 'writeFile', + 'importFile', 'makeDirectory', 'copyPath', 'renamePath', 'removePath', + 'archivesSupported', 'readAcl', 'changeAclEntries', 'getACLAccess' +); +foreach ($providerMethods as $method) { + afs_test_contains( + $providerInterface, + 'public function ' . $method . '(', + 'provider interface declares ' . $method + ); +} + +$bundledReadiness = afs_test_section( + $afs, + 'class AfsDataPlane extends Afs implements AfsDataPlaneProvider', + 'public function initializeDataPlane', + 'bundled provider readiness' +); +afs_test_contains( + $bundledReadiness, + "public function isProductionReady()\n {\n return false;", + 'bundled pathname provider cannot claim production readiness' +); +afs_test_contains( + $bundledReadiness, + "return 'pathname-preview';", + 'bundled pathname provider cannot advertise the descriptor boundary token' +); + +$providerReadiness = afs_test_section( + $manager, + '$afsDataPlane = null;', + '// always use ?p=', + 'provider startup readiness' +); +afs_test_contains( + $providerReadiness, + 'if (!($afsDataPlaneFactory instanceof AfsDataPlaneProviderFactory))', + 'AFS startup requires the typed provider-factory interface' +); +afs_test_not_contains( + $providerReadiness, + 'is_callable(', + 'AFS startup has no legacy untyped callable-factory fallback' +); +afs_test_contains( + $providerReadiness, + 'get_class($afsDataPlaneFactory) !== $afs_expected_factory_class', + 'AFS startup requires the exact configured factory class' +); +afs_test_contains( + $providerReadiness, + '$afsDataPlaneFactory->getFactoryIdentity()', + 'AFS startup reads the factory-declared identity' +); +afs_test_contains( + $providerReadiness, + '!== $afs_expected_factory_id', + 'AFS startup requires the exact configured factory identity' +); +afs_test_contains( + $providerReadiness, + '$afsDataPlaneFactory->createProvider(', + 'AFS startup obtains the provider from the typed factory' +); +afs_test_contains( + $providerReadiness, + 'FM_ROOT_PATH, $afsRequestIdentity);', + 'factory creation binds the reviewed root and snapshotted request identity' +); +afs_test_contains( + $providerReadiness, + 'if (!($afsDataPlane instanceof AfsDataPlaneProvider))', + 'AFS startup requires the provider interface' +); +afs_test_contains( + $providerReadiness, + 'get_class($afsDataPlane) !== $afs_expected_provider_class', + 'AFS startup requires the exact configured provider class' +); +afs_test_contains( + $providerReadiness, + '$afsDataPlane->getProviderIdentity()', + 'AFS startup reads the provider-declared identity' +); +afs_test_contains( + $providerReadiness, + '!== $afs_expected_provider_id', + 'AFS startup requires the exact configured provider identity' +); +afs_test_contains( + $providerReadiness, + '$afsDataPlane->getCredentialIdentity()', + 'AFS startup reads the provider credential identity' +); +afs_test_contains( + $providerReadiness, + '!== $afsRequestIdentity', + 'AFS startup binds provider credentials to the snapshotted request identity' +); +afs_test_not_contains( + $providerReadiness, + "\$_SERVER['REMOTE_USER']", + 'provider startup never re-reads mutable request identity state' +); +afs_test_contains( + $providerReadiness, + 'if ($afsDataPlane->isProductionReady() !== true)', + 'AFS startup accepts only literal true readiness' +); +afs_test_contains( + $providerReadiness, + '!== AfsDataPlaneProvider::SECURITY_BOUNDARY_DESCRIPTOR_BENEATH_V1', + 'AFS startup requires the exact descriptor boundary token' +); +afs_test_contains( + $providerReadiness, + 'if ($afsDataPlane->initializeDataPlane(FM_ROOT_PATH) !== true)', + 'AFS startup accepts only literal true provider initialization' +); +$providerReadyPos = strpos($providerReadiness, '->isProductionReady() !== true'); +$providerBoundaryPos = strpos($providerReadiness, '->getSecurityBoundary()'); +$providerInitPos = strpos($providerReadiness, '->initializeDataPlane(FM_ROOT_PATH) !== true'); +$factoryClassPos = strpos($providerReadiness, 'get_class($afsDataPlaneFactory)'); +$providerCreatePos = strpos($providerReadiness, '->createProvider('); +$credentialPos = strpos($providerReadiness, '->getCredentialIdentity()'); +afs_test_ok( + $factoryClassPos !== false && $providerCreatePos !== false + && $credentialPos !== false && $providerReadyPos !== false + && $providerBoundaryPos !== false + && $providerInitPos !== false + && $factoryClassPos < $providerCreatePos + && $providerCreatePos < $credentialPos + && $credentialPos < $providerReadyPos + && $providerReadyPos < $providerBoundaryPos + && $providerBoundaryPos < $providerInitPos, + 'factory/provider identity, readiness, and boundary checks precede initialization' +); + +$legacyReturnUri = afs_test_section( + $afs, + 'function get_returnToURI()', + 'Return a string escaped for a javascript string literal.', + 'legacy AFS return URI' +); +afs_test_contains( + $legacyReturnUri, + 'FM_SELF_URL', + 'legacy AFS return URI uses the validated controller URL' +); +afs_test_not_contains( + $legacyReturnUri, + 'HTTP_HOST', + 'legacy AFS return URI never trusts the request Host header' +); +afs_test_not_contains( + $legacyReturnUri, + "\$_SERVER['PHP_SELF']", + 'legacy AFS return URI never re-reads an unvalidated request path' +); + +// The immutable profile validates the application's constructed state, not a +// deployment-supplied assertion. Its trusted request identity is snapshotted +// once and later passed unchanged to the provider factory. +$profileState = afs_test_section( + $manager, + '$afsSelfUrl = isset($_SERVER[\'SCRIPT_NAME\'])', + "define('ACE_FONTSIZE'", + 'AFS actual production-profile state' +); +afs_test_contains( + $profileState, + "\$afsRequestIdentity = isset(\$_SERVER['REMOTE_USER'])", + 'AFS profile snapshots the externally authenticated request identity' +); +afs_test_contains( + $profileState, + '$afsDataRoot = $root_path;', + 'AFS profile snapshots its configured data root once' +); +$actualProfileFields = array( + "'profile' => defined('AFS_PRODUCTION_PROFILE')" => + 'profile state reads the immutable profile constant', + '? AFS_PRODUCTION_PROFILE : null' => + 'profile state records the actual immutable profile value', + "'afs_enabled' => \$afsSupport" => + 'profile state records actual AFS enablement', + "'external_auth' => \$afs_external_auth" => + 'profile state records actual external-auth enablement', + "'request_identity' => \$afsRequestIdentity" => + 'profile state records the snapshotted request identity', + "'local_auth' => \$use_auth" => + 'profile state records actual local-auth state', + "'local_users_empty' => is_array(\$auth_users)" => + 'profile state validates the actual local-user collections', + '&& empty($auth_users) && empty($readonly_users)' => + 'profile state requires auth and readonly user maps to be empty', + '&& empty($directories_users)' => + 'profile state requires per-user directory mappings to be empty', + "'settings_enabled' => \$settings_enabled" => + 'profile state records actual settings enablement', + "'embed_enabled' => defined('FM_EMBED')" => + 'profile state records the actual embed constant', + "'direct_links_enabled' => \$direct_links_enabled" => + 'profile state records actual direct-link enablement', + "'raw_previews_enabled' => \$raw_previews_enabled" => + 'profile state records actual raw-preview enablement', + "'url_upload_enabled' => \$url_upload_enabled" => + 'profile state records actual URL-upload enablement', + "'root_url' => \$root_url" => + 'profile state records the actual managed-root URL', + "'self_url' => \$afsSelfUrl" => + 'profile state records the actual controller URL', + "'data_root' => \$afsDataRoot" => + 'profile state records the snapshotted data root', + "'asset_manifest_sha256' => \$afs_asset_manifest_sha256" => + 'profile state records the reviewed manifest digest', + "'expected_factory_class' => \$afs_expected_factory_class" => + 'profile state records the expected factory class', + "'expected_factory_id' => \$afs_expected_factory_id" => + 'profile state records the expected factory identity', + "'expected_provider_class' => \$afs_expected_provider_class" => + 'profile state records the expected provider class', + "'expected_provider_id' => \$afs_expected_provider_id" => + 'profile state records the expected provider identity' +); +foreach ($actualProfileFields as $needle => $message) { + afs_test_contains($profileState, $needle, $message); +} +afs_test_contains( + $profileState, + 'AfsProductionReadiness::validateProductionProfile(', + 'manager validates the constructed actual production-profile state' +); +afs_test_contains( + $profileState, + "if (defined('FM_ROOT_PATH') && FM_ROOT_PATH !== \$afsDataRoot)", + 'AFS rejects a pre-defined FM_ROOT_PATH that differs from the profile root' +); +$profileStateValidationPos = strpos( + $profileState, + 'AfsProductionReadiness::validateProductionProfile(' +); +$predefinedRootGatePos = strpos( + $profileState, + "if (defined('FM_ROOT_PATH') && FM_ROOT_PATH !== \$afsDataRoot)" +); +afs_test_ok( + $profileStateValidationPos !== false && $predefinedRootGatePos !== false + && $profileStateValidationPos < $predefinedRootGatePos, + 'profile validation precedes the pre-defined FM_ROOT_PATH equality gate' +); + +$profileValidator = afs_test_section( + $afs, + 'public static function validateProductionProfile(', + 'public static function applicationTemplatesSupportStrictCsp', + 'immutable AFS production-profile validator' +); +afs_test_contains( + $profileValidator, + "'profile' => self::PRODUCTION_PROFILE", + 'profile validator requires its immutable version token' +); +$fixedProfileValues = array( + "'afs_enabled' => true", + "'external_auth' => true", + "'local_auth' => false", + "'local_users_empty' => true", + "'settings_enabled' => false", + "'embed_enabled' => false", + "'direct_links_enabled' => false", + "'raw_previews_enabled' => false", + "'url_upload_enabled' => false", + "'root_url' => ''" +); +foreach ($fixedProfileValues as $fixedProfileValue) { + afs_test_contains( + $profileValidator, + $fixedProfileValue, + 'immutable profile fixes ' . $fixedProfileValue + ); +} +afs_test_contains( + $profileValidator, + "preg_match( '/[\\x00-\\x1f\\x7f]/', \$state['request_identity'] )", + 'profile rejects control bytes in the trusted external identity' +); +afs_test_contains( + $profileValidator, + "!is_string( \$state['data_root'] )", + 'profile requires a string data root' +); +afs_test_contains( + $profileValidator, + "strpos( \$state['data_root'], '/afs/' ) !== 0", + 'profile requires the data root to be strictly below /afs' +); +afs_test_contains( + $profileValidator, + "rtrim( \$state['data_root'], '/' ) !== \$state['data_root']", + 'profile rejects a trailing slash in the data root' +); +afs_test_contains( + $profileValidator, + "strpos( \$state['data_root'], '\\\\' ) !== false", + 'profile rejects backslashes in the data root' +); +afs_test_contains( + $profileValidator, + "preg_match( '/[\\x00-\\x1f\\x7f]/', \$state['data_root'] )", + 'profile rejects control bytes in the data root' +); +afs_test_contains( + $profileValidator, + "explode( '/', substr( \$state['data_root'], 5 ))", + 'profile validates every data-root path segment' +); +afs_test_contains( + $profileValidator, + "\$segment === '' || \$segment === '.' || \$segment === '..'", + 'profile rejects empty and dot segments in the data root' +); +afs_test_contains( + $profileValidator, + "!is_string( \$state['asset_manifest_sha256'] )", + 'profile requires a string manifest digest' +); +afs_test_contains( + $profileValidator, + "preg_match( '/^[a-f0-9]{64}$/',", + 'profile requires a lowercase 64-hex manifest digest' +); +afs_test_contains( + $profileValidator, + "\$state['asset_manifest_sha256']", + 'profile validates the reviewed manifest digest from actual state' +); +afs_test_contains( + $afs, + "const PRODUCTION_PROFILE = 'afs-descriptor-v1';", + 'immutable AFS production profile has the reviewed versioned value' +); +$profileKeys = array( + 'profile', 'afs_enabled', 'external_auth', 'request_identity', + 'local_auth', 'local_users_empty', 'settings_enabled', 'embed_enabled', + 'direct_links_enabled', 'raw_previews_enabled', 'url_upload_enabled', + 'root_url', 'self_url', + 'data_root', 'asset_manifest_sha256', + 'expected_factory_class', 'expected_factory_id', + 'expected_provider_class', 'expected_provider_id' +); +foreach ($profileKeys as $profileKey) { + afs_test_contains( + $profileValidator, + "'" . $profileKey . "'", + 'immutable profile declares actual-state field ' . $profileKey + ); +} +afs_test_contains( + $profileValidator, + 'count( $state ) !== count( $keys )', + 'immutable profile rejects missing or extra actual-state field counts' +); +afs_test_contains( + $profileValidator, + 'array_diff_key( array_flip( $keys ), $state )', + 'immutable profile rejects missing actual-state fields' +); +afs_test_contains( + $profileValidator, + 'array_diff_key( $state, array_flip( $keys ))', + 'immutable profile rejects unreviewed actual-state fields' +); +$profileValidationPos = strpos( + $manager, + 'AfsProductionReadiness::validateProductionProfile(' +); +$embedRuntimePos = strpos($manager, "if (defined('FM_EMBED')) {"); +$localAuthRuntimePos = strpos($manager, 'if ($use_auth) {'); +afs_test_ok( + $profileValidationPos !== false && $embedRuntimePos !== false + && $localAuthRuntimePos !== false + && $profileValidationPos < $embedRuntimePos + && $profileValidationPos < $localAuthRuntimePos, + 'profile rejects embed/local-auth state before authentication dispatch' +); + +$rootBinding = afs_test_section( + $manager, + '// Use the single post-config profile snapshot;', + "defined('FM_LANG')", + 'final AFS data-root binding' +); +afs_test_contains( + $rootBinding, + '$root_path = $afsDataRoot;', + 'AFS rebinds mutable root_path to the single profile snapshot' +); +afs_test_contains( + $rootBinding, + "defined('FM_ROOT_PATH') || define('FM_ROOT_PATH', \$root_path);", + 'FM_ROOT_PATH is defined from the rebound profile root' +); +afs_test_contains( + $rootBinding, + 'if ($afsSupport && FM_ROOT_PATH !== $afsDataRoot)', + 'AFS asserts the final FM_ROOT_PATH still equals the profile snapshot' +); +$dataRootSnapshotPos = strpos($manager, '$afsDataRoot = $root_path;'); +$rootRebindPos = strpos( + $manager, + '$root_path = $afsDataRoot;', + $dataRootSnapshotPos === false ? 0 : $dataRootSnapshotPos +); +$rootDefinePos = strpos( + $manager, + "defined('FM_ROOT_PATH') || define('FM_ROOT_PATH', \$root_path);" +); +$rootFinalGatePos = strpos( + $manager, + 'if ($afsSupport && FM_ROOT_PATH !== $afsDataRoot)' +); +$factoryRootPos = strpos( + $manager, + 'FM_ROOT_PATH, $afsRequestIdentity);' +); +$initializeRootPos = strpos( + $manager, + '->initializeDataPlane(FM_ROOT_PATH) !== true' +); +afs_test_ok( + $dataRootSnapshotPos !== false && $rootRebindPos !== false + && $rootDefinePos !== false && $rootFinalGatePos !== false + && $factoryRootPos !== false && $initializeRootPos !== false + && $dataRootSnapshotPos < $rootRebindPos + && $rootRebindPos < $rootDefinePos + && $rootDefinePos < $rootFinalGatePos + && $rootFinalGatePos < $factoryRootPos + && $factoryRootPos < $initializeRootPos, + 'one snapshotted FM_ROOT_PATH reaches factory creation and initialization' +); + +// Configurable and pre-defined feature flags are both checked. The final +// constants must still be literal false before any provider or route runs. +$featureConstants = afs_test_section( + $manager, + "if (\$afsSupport && ((defined('FM_SETTINGS_ENABLED')", + '$afsDataPlane = null;', + 'final AFS feature-constant gates' +); +$featureConstantNames = array( + 'FM_SETTINGS_ENABLED', 'FM_DIRECT_LINKS_ENABLED', + 'FM_RAW_PREVIEWS_ENABLED', 'FM_URL_UPLOAD_ENABLED' +); +foreach ($featureConstantNames as $featureConstantName) { + afs_test_contains( + $featureConstants, + "defined('" . $featureConstantName . "')", + 'AFS rejects a pre-defined ' . $featureConstantName + ); + afs_test_contains( + $featureConstants, + $featureConstantName . ' !== false', + 'AFS requires literal false ' . $featureConstantName + ); +} +afs_test_contains( + $featureConstants, + "defined('FM_SETTINGS_ENABLED') || define('FM_SETTINGS_ENABLED', \$settings_enabled);", + 'final settings constant derives from validated actual state' +); +afs_test_contains( + $featureConstants, + "defined('FM_DIRECT_LINKS_ENABLED') || define('FM_DIRECT_LINKS_ENABLED', \$direct_links_enabled);", + 'final direct-link constant derives from validated actual state' +); +afs_test_contains( + $featureConstants, + "defined('FM_RAW_PREVIEWS_ENABLED') || define('FM_RAW_PREVIEWS_ENABLED', \$raw_previews_enabled);", + 'final raw-preview constant derives from validated actual state' +); +afs_test_contains( + $featureConstants, + "defined('FM_URL_UPLOAD_ENABLED') || define('FM_URL_UPLOAD_ENABLED', \$url_upload_enabled);", + 'final URL-upload constant derives from validated actual state' +); +afs_test_contains( + $featureConstants, + 'AFS production features did not remain fail-closed.', + 'AFS rechecks final feature constants after definition' +); + +$settingsAjax = afs_test_section( + $manager, + '// Save Config', + '//upload using url', + 'settings AJAX utilities' +); +afs_test_contains( + $settingsAjax, + 'if (!FM_SETTINGS_ENABLED || fm_is_afs_mode())', + 'settings mutation is rejected when disabled and always in AFS mode' +); +afs_test_contains( + $settingsAjax, + 'if (!FM_SETTINGS_ENABLED)', + 'password-hash utility is rejected when settings are disabled' +); +$settingsPage = afs_test_section( + $manager, + "if (isset(\$_GET['settings']) && !FM_SETTINGS_ENABLED)", + '// file viewer', + 'settings page route' +); +afs_test_contains( + $settingsPage, + "if (isset(\$_GET['settings']) && !FM_SETTINGS_ENABLED)", + 'disabled settings page requests are explicitly rejected' +); +afs_test_contains( + $settingsPage, + "isset(\$_GET['settings']) && !FM_READONLY && FM_SETTINGS_ENABLED", + 'settings page rendering requires the enabled flag' +); +$configWriter = afs_test_section( + $manager, + 'class FM_Config', + 'function fm_show_nav_path($path)', + 'configuration writer' +); +afs_test_contains( + $configWriter, + "function save()\n {\n if (fm_is_afs_mode()) {\n return false;", + 'configuration writer independently refuses AFS mutations' +); + +// AFS assets are structured input: exact typed rows and reviewed SHA-256 +// digests. Raw config-provided HTML remains a non-AFS-only compatibility path. +$readinessClass = afs_test_section( + $afs, + 'class AfsProductionReadiness', + 'class AfsDataPlane extends Afs implements AfsDataPlaneProvider', + 'AFS production-readiness class' +); +$assetBuilder = afs_test_section( + $readinessClass, + 'public static function buildLocalAssetTags(', + 'public static function buildLocalAssetTagsFromManifestFile(', + 'typed local-asset builder' +); +$assetKeys = array( + 'css-bootstrap', 'css-dropzone', 'css-font-awesome', + 'css-highlightjs', 'js-ace', 'js-bootstrap', 'js-dropzone', + 'js-jquery', 'js-jquery-datatables', 'js-highlightjs' +); +foreach ($assetKeys as $assetKey) { + afs_test_contains( + $assetBuilder, + "'" . $assetKey . "' =>", + 'typed asset manifest requires ' . $assetKey + ); +} +afs_test_contains( + $assetBuilder, + 'count( $manifest ) !== count( $types )', + 'typed asset manifest rejects missing or extra key counts' +); +afs_test_contains( + $assetBuilder, + 'array_diff_key( $manifest, $types )', + 'typed asset manifest rejects unreviewed keys' +); +afs_test_contains( + $assetBuilder, + "'type' => true, 'path' => true, 'sha256' => true,", + 'each asset row allowlists type, path, and digest fields' +); +afs_test_contains( + $assetBuilder, + "'license' => true, 'defer' => true", + 'each asset row allowlists license and defer fields' +); +afs_test_contains( + $assetBuilder, + "|| !isset( \$entry['type'], \$entry['path'], \$entry['sha256'],", + 'type, path, and SHA-256 are mandatory in every asset row' +); +afs_test_contains( + $assetBuilder, + "\$entry['license'], \$entry['defer'] )", + 'license and defer are mandatory in every asset row' +); +afs_test_contains( + $assetBuilder, + "|| !is_bool( \$entry['defer'] )", + 'defer metadata must be boolean for every asset row' +); +afs_test_contains( + $assetBuilder, + "\$expectedType === 'style' && \$entry['defer'] !== false", + 'style assets require literal false defer metadata' +); +afs_test_contains( + $assetBuilder, + "'MIT', 'BSD-3-Clause', 'Apache-2.0', 'OFL-1.1'", + 'asset licenses use the reviewed SPDX allowlist' +); +afs_test_contains( + $assetBuilder, + ' afs_test_section($manager, '// Delete file / folder', '// Create a new file/folder', 'single-delete route'), + 'create' => afs_test_section($manager, '// Create a new file/folder', '// Copy folder / file', 'create route'), + 'single copy/move' => afs_test_section($manager, '// Complete a single copy/move', '// Mass copy files/ folders', 'single-copy route'), + 'mass copy/move' => afs_test_section($manager, '// Mass copy files/ folders', '// Rename', 'mass-copy route'), + 'rename' => afs_test_section($manager, '// Rename', '// Download', 'rename route'), + 'download' => afs_test_section($manager, '// Download', '// Upload', 'download route'), + 'upload' => afs_test_section($manager, '// Upload', '// Mass deleting', 'upload route'), + 'mass delete' => afs_test_section($manager, '// Mass deleting', '// Pack files zip, tar', 'mass-delete route'), + 'archive create' => afs_test_section($manager, '// Pack files zip, tar', '// Unpack zip, tar', 'archive-create route'), + 'archive extract' => afs_test_section($manager, '// Unpack zip, tar', '// Change POSIX permissions', 'archive-extract route'), + 'POSIX chmod' => afs_test_section($manager, '// Change POSIX permissions', '// Change AFS ACLs', 'POSIX-chmod route'), + 'AFS ACL write' => afs_test_section($manager, '// Change AFS ACLs', '/*************************** ACTIONS', 'AFS-ACL-write route') +); + +foreach ($csrfSections as $label => $section) { + afs_test_contains($section, "verifyToken(\$_POST['token'])", $label . ' preserves token verification'); +} + +// This was a pre-existing upstream GET mutation, not an AFS replay change. +// Completion must remain a token-verified POST while GET is navigation-only. +$singleCopy = $csrfSections['single copy/move']; +afs_test_contains( + $singleCopy, + "isset(\$_POST['copy'], \$_POST['finish'], \$_POST['token'])", + 'single-copy completion requires POST fields and a CSRF token' +); +afs_test_ok( + strpos($singleCopy, "\$_GET['finish']") === false, + 'single-copy completion has no mutating GET finish route' +); +$singleCopyVerify = strpos($singleCopy, "verifyToken(\$_POST['token'])"); +$singleCopyMutation = strpos($singleCopy, 'fm_rename('); +afs_test_ok( + $singleCopyVerify !== false && $singleCopyMutation !== false + && $singleCopyVerify < $singleCopyMutation, + 'single-copy token verification precedes copy or move mutation' +); + +$singleCopyUi = afs_test_section( + $manager, + "// copy form\nif (isset(\$_GET['copy'])", + "if (isset(\$_GET['settings'])", + 'single-copy navigation form' +); +afs_test_contains($singleCopyUi, 'method="post"', + 'single-copy completion UI submits by POST'); +afs_test_contains($singleCopyUi, 'name="token"', + 'single-copy completion UI submits the session token'); +afs_test_contains($singleCopyUi, 'name="copy"', + 'single-copy completion UI submits the source path'); +afs_test_contains($singleCopyUi, 'name="finish" value="1"', + 'single-copy completion UI submits the completion marker'); +afs_test_contains($singleCopyUi, 'name="move" value="1"', + 'single-copy completion UI distinguishes move from copy'); +afs_test_ok( + strpos($singleCopyUi, '&finish=1') === false, + 'single-copy completion UI emits no state-changing GET links' +); + +$verifyFunction = afs_test_section($manager, 'function verifyToken($token)', 'function fm_rdelete($path)', 'verifyToken function'); +afs_test_contains($verifyFunction, 'hash_equals(', 'token comparison remains timing-safe'); + +// Every AFS path, metadata, content, MIME, and mutation helper must delegate +// to the provider without accepting loose truthy success values. +$resolveExisting = afs_test_section( + $manager, + 'function fm_resolve_existing_path(', + 'function fm_resolve_write_path(', + 'existing-path provider wrapper' +); +afs_test_contains( + $resolveExisting, + '$provider->resolveExistingPath($path, $type)', + 'existing-path resolution delegates to the provider' +); +afs_test_contains( + $resolveExisting, + "return is_string(\$resolved) && \$resolved !== '' ? \$resolved : false;", + 'existing-path resolution accepts only a nonempty provider string' +); + +$resolveWrite = afs_test_section( + $manager, + 'function fm_resolve_write_path(', + 'function fm_inspect_path(', + 'write-path provider wrapper' +); +afs_test_contains( + $resolveWrite, + '$provider->resolveWritePath($path, $allowExisting)', + 'write-path resolution delegates to the provider' +); +afs_test_contains( + $resolveWrite, + "return is_string(\$resolved) && \$resolved !== '' ? \$resolved : false;", + 'write-path resolution accepts only a nonempty provider string' +); + +$inspectPath = afs_test_section( + $manager, + 'function fm_inspect_path(', + 'function fm_path_exists(', + 'metadata provider wrapper' +); +afs_test_contains( + $inspectPath, + '$provider->inspectPath($path, $allowLinkObject)', + 'AFS metadata inspection delegates to the provider' +); +afs_test_contains( + $inspectPath, + "\$info['path'], \$info['type'], \$info['size'],", + 'provider metadata requires path, type, size, timestamp, and mode' +); +afs_test_contains( + $inspectPath, + "in_array(\$info['type'], array('file', 'dir', 'link'), true)", + 'provider metadata type uses a strict allowlist' +); +afs_test_contains( + $inspectPath, + "|| !is_string(\$info['link_target'])", + 'provider link metadata requires a string target' +); + +$readContents = afs_test_section( + $manager, + 'function fm_read_file_contents(', + 'function fm_write_file_contents(', + 'content-read provider wrapper' +); +afs_test_contains( + $readContents, + '$provider->readContents($path)', + 'AFS content reads delegate to the provider' +); +afs_test_contains( + $readContents, + 'return is_string($contents) ? $contents : false;', + 'AFS content reads reject non-string provider results' +); + +$mimeType = afs_test_section( + $manager, + 'function fm_get_mime_type(', + 'function fm_redirect(', + 'MIME provider wrapper' +); +afs_test_contains( + $mimeType, + '$provider->detectMimeType($file_path)', + 'AFS MIME detection delegates to the provider' +); +afs_test_contains( + $mimeType, + "? \$mime : 'application/octet-stream';", + 'invalid provider MIME output fails to the binary-safe default' +); + +$strictMutationFunctions = array( + array( + 'write-file', 'function fm_write_file_contents(', + 'function fm_create_file(', '->writeFile(' + ), + array( + 'create-file', 'function fm_create_file(', + 'function fm_import_file(', '->createFile(' + ), + array( + 'import-file', 'function fm_import_file(', + 'function fm_afs_archives_supported(', '->importFile(' + ), + array( + 'delete', 'function fm_rdelete(', + 'function fm_rchmod(', '->removePath(' + ), + array( + 'recursive-copy', 'function fm_rcopy(', + 'function fm_mkdir(', '->copyPath(' + ), + array( + 'make-directory', 'function fm_mkdir(', + 'function fm_copy(', '->makeDirectory(' + ), + array( + 'single-copy', 'function fm_copy(', + 'function fm_get_mime_type(', '->copyPath(' + ) +); +foreach ($strictMutationFunctions as $mutationContract) { + $mutationSection = afs_test_section( + $manager, + $mutationContract[1], + $mutationContract[2], + $mutationContract[0] . ' provider wrapper' + ); + afs_test_contains( + $mutationSection, + $mutationContract[3], + $mutationContract[0] . ' delegates mutation to the provider' + ); + afs_test_contains( + $mutationSection, + '=== true', + $mutationContract[0] . ' accepts only literal true provider success' + ); +} + +$renameWrapper = afs_test_section( + $manager, + 'function fm_rename(', + 'function fm_rcopy(', + 'rename provider wrapper' +); +afs_test_contains( + $renameWrapper, + '$result = $provider->renamePath($old, $new);', + 'rename delegates mutation to the provider' +); +afs_test_contains( + $renameWrapper, + 'return $result === true ? true : ($result === null ? null : false);', + 'rename preserves only literal true, null collision, or false failure' +); +afs_test_contains( + $manager, + '$stored = fm_rename($partPath, $fullPathTarget) === true;', + 'chunk finalization requires literal true rename success' +); +afs_test_ok( + strpos($manager, 'new Afs(') === false, + 'active Tiny File Manager routes never bypass the configured provider with new Afs' +); + +// AFS production rejects URL-upload egress before URL parsing, temporary-file +// creation, or network I/O. Non-AFS mode retains the upstream validation and +// the fork's proxy path behind that early feature gate. +$urlUpload = afs_test_section($manager, '//upload using url', " exit();\n}", 'URL-upload route'); +afs_test_contains( + $urlUpload, + '$urlUploadRequested = isset($_POST[\'type\'])', + 'URL-upload request detection starts from the POST action' +); +afs_test_contains( + $urlUpload, + "\$_POST['type'] === 'upload'", + 'URL-upload request detection requires the upload action' +); +afs_test_contains( + $urlUpload, + "array_key_exists('uploadurl', \$_REQUEST)", + 'URL-upload request detection requires the URL field' +); +afs_test_contains( + $urlUpload, + 'if ($urlUploadRequested && FM_URL_UPLOAD_ENABLED !== true)', + 'disabled URL upload is rejected for every detected request' +); +afs_test_contains( + $urlUpload, + "header('HTTP/1.1 403 Forbidden');", + 'disabled URL upload returns HTTP 403' +); +afs_test_contains( + $urlUpload, + "'message' => 'URL upload is disabled'", + 'disabled URL upload returns an explicit failure response' +); +$urlUploadGatePos = strpos( + $urlUpload, + 'if ($urlUploadRequested && FM_URL_UPLOAD_ENABLED !== true)' +); +$urlUploadDenyExitPos = $urlUploadGatePos === false ? false + : strpos($urlUpload, 'exit();', $urlUploadGatePos); +$urlUploadParsePos = strpos($urlUpload, 'parse_url($url, PHP_URL_HOST)'); +$urlUploadTempPos = strpos($urlUpload, 'tempnam(sys_get_temp_dir(), "upload-")'); +$urlUploadCopyPos = strpos($urlUpload, 'copy($url, $temp_file, $ctx)'); +afs_test_ok( + $urlUploadGatePos !== false && $urlUploadDenyExitPos !== false + && $urlUploadParsePos !== false && $urlUploadTempPos !== false + && $urlUploadCopyPos !== false + && $urlUploadGatePos < $urlUploadDenyExitPos + && $urlUploadDenyExitPos < $urlUploadParsePos + && $urlUploadDenyExitPos < $urlUploadTempPos + && $urlUploadDenyExitPos < $urlUploadCopyPos, + 'URL-upload denial exits before parse_url, tempnam, and network copy' +); +afs_test_contains($urlUpload, 'preg_match("|^http(s)?://.+$|"', 'URL upload accepts only HTTP(S)-shaped URLs'); +afs_test_contains($urlUpload, 'parse_url($url, PHP_URL_HOST)', 'URL upload parses the destination host'); +afs_test_contains($urlUpload, 'parse_url($url, PHP_URL_PORT)', 'URL upload parses the destination port'); +afs_test_contains($urlUpload, '^localhost$', 'URL upload rejects localhost'); +afs_test_contains($urlUpload, '^127', 'URL upload rejects IPv4 loopback'); +afs_test_contains($urlUpload, '0*1$', 'URL upload rejects IPv6 loopback'); +afs_test_contains($urlUpload, '$knownPorts = [22, 23, 25, 3306];', 'URL upload preserves the blocked-port baseline'); +afs_test_contains($urlUpload, 'basename($fileinfo->name)', 'URL-upload destination is reduced to a basename'); +afs_test_contains($urlUpload, "strtok(get_file_path(), '?')", 'URL-upload destination strips a query suffix'); +afs_test_contains($urlUpload, "'proxy' => 'tcp://' . \$proxyServer", 'non-cURL URL upload preserves configured proxy support'); +afs_test_contains($urlUpload, "'request_fulluri' => true", 'proxy requests retain absolute request URIs'); + +$uploadPage = afs_test_section( + $manager, + '// upload form', + '// file viewer', + 'upload page and client script' +); +$urlUploadUiGuard = ''; +$urlUploadTabPos = strpos($uploadPage, 'href="#urlUploader"'); +$urlUploadTabGuardPos = strpos($uploadPage, $urlUploadUiGuard); +$urlUploadTabEndPos = $urlUploadTabPos === false ? false + : strpos($uploadPage, '', $urlUploadTabPos); +$urlUploadFormGuardPos = $urlUploadTabEndPos === false ? false + : strpos($uploadPage, $urlUploadUiGuard, $urlUploadTabEndPos); +$urlUploadFormPos = strpos($uploadPage, 'id="js-form-url-upload"'); +$urlUploadFormEndPos = $urlUploadFormPos === false ? false + : strpos($uploadPage, '', $urlUploadFormPos); +afs_test_ok( + substr_count($uploadPage, $urlUploadUiGuard) === 2 + && $urlUploadTabGuardPos !== false && $urlUploadTabPos !== false + && $urlUploadTabEndPos !== false + && $urlUploadTabGuardPos < $urlUploadTabPos + && $urlUploadTabPos < $urlUploadTabEndPos + && $urlUploadFormGuardPos !== false && $urlUploadFormPos !== false + && $urlUploadFormEndPos !== false + && $urlUploadFormGuardPos < $urlUploadFormPos + && $urlUploadFormPos < $urlUploadFormEndPos, + 'URL-upload tab and form are both omitted unless explicitly enabled' +); + +$urlUploadClient = afs_test_section( + $manager, + " \n" + . ' // Upload files using URL @param {Object}', + ' // Search template', + 'footer URL-upload client script' +); +$urlUploadScriptGuardPos = strpos($urlUploadClient, $urlUploadUiGuard); +$urlUploadScriptPos = strpos( + $urlUploadClient, + 'function upload_from_url($this)' +); +$urlUploadScriptEndPos = $urlUploadScriptPos === false ? false + : strpos($urlUploadClient, '', $urlUploadScriptPos); +afs_test_ok( + substr_count($urlUploadClient, $urlUploadUiGuard) === 1 + && $urlUploadScriptGuardPos !== false && $urlUploadScriptPos !== false + && $urlUploadScriptEndPos !== false + && $urlUploadScriptGuardPos < $urlUploadScriptPos + && $urlUploadScriptPos < $urlUploadScriptEndPos, + 'URL-upload JavaScript is omitted unless explicitly enabled' +); + +// Preserve exclusion behavior added upstream: configured exact names, wildcard +// extensions, and full paths all remain excluded from listing/view/edit. +afs_test_contains( + $manager, + "version_compare(PHP_VERSION, '7.0.0', '<') ? serialize(\$exclude_items) : \$exclude_items", + 'FM_EXCLUDE_ITEMS preserves the PHP 5 serialization compatibility path' +); +$excludeFunction = afs_test_section($manager, 'function fm_is_exclude_items($name, $path)', 'function fm_get_translations($tr)', 'exclusion helper'); +afs_test_contains($excludeFunction, 'in_array($name, $exclude_items)', 'exclusion helper checks exact names'); +afs_test_contains($excludeFunction, 'in_array("*.$ext", $exclude_items)', 'exclusion helper checks wildcard extensions'); +afs_test_contains($excludeFunction, 'in_array($path, $exclude_items)', 'exclusion helper checks full paths'); +afs_test_ok( + substr_count($manager, 'fm_is_exclude_items(') >= 6, + 'listing, view, and edit paths retain exclusion checks' +); +afs_test_contains($manager, "strpbrk(\$text, '/?%*:|\"<>' . chr(0))", 'current null-byte filename rejection is preserved'); + +// ACL write handling must round-trip both lists. Accept either two explicit +// branches or a normal=>false/negative=>true mode map feeding the batched +// negative-mode argument. +$aclSubmit = $csrfSections['AFS ACL write']; +$readBeforeChange = strpos($aclSubmit, 'fm_read_afs_acl($aclPath)'); +$changeCall = strpos($aclSubmit, 'fm_change_afs_acl_entries('); +afs_test_ok( + $readBeforeChange !== false && $changeCall !== false && $readBeforeChange < $changeCall, + 'ACL writes re-read current inheritance state before fs setacl' +); +afs_test_contains($aclSubmit, "!empty(\$currentAcl['inherited'])", + 'inherited AuriStor ACL writes fail closed server-side'); +$mappedAclSets = preg_match('/[\'\"]normal[\'\"]\s*=>\s*false.*[\'\"]negative[\'\"]\s*=>\s*true/s', $aclSubmit) === 1; +$dynamicAclLookup = strpos($aclSubmit, '$_POST[$setName]') !== false; +afs_test_ok( + strpos($aclSubmit, "\$_POST['normal']") !== false || ($mappedAclSets && $dynamicAclLookup), + 'positive ACL submissions are handled' +); +afs_test_ok( + strpos($aclSubmit, "\$_POST['negative']") !== false || ($mappedAclSets && $dynamicAclLookup), + 'negative ACL submissions are handled' +); +$sentinelIgnored = strpos($aclSubmit, "unset(\$perms['acl'])") !== false + || (strpos($aclSubmit, '$allowedRights') !== false + && strpos($aclSubmit, 'isset($perms[$right])') !== false); +afs_test_ok($sentinelIgnored, 'ACL empty-rights sentinel is excluded from assembled rights'); +$emptyBecomesNone = strpos($aclSubmit, "empty(\$perms) ? 'none'") !== false + || preg_match('/\$newAcl\s*=\s*\$newAcl\s*==={0,1}\s*[\'\"]{2}\s*\?\s*[\'\"]none[\'\"]/', $aclSubmit) === 1; +afs_test_ok($emptyBecomesNone, 'clearing every right is translated to fs none'); + +$directNegativeCall = preg_match('/(?:changeAclEntries|fm_change_afs_acl_entries)\s*\([^;]*,\s*true\s*\)/s', $aclSubmit) === 1; +$modeMap = $mappedAclSets; +$variableNegativeCall = preg_match('/(?:changeAclEntries|fm_change_afs_acl_entries)\s*\([^;]*,\s*\$[A-Za-z_][A-Za-z0-9_]*\s*\)/s', $aclSubmit) === 1; +afs_test_ok( + $directNegativeCall || ($modeMap && $variableNegativeCall), + 'negative ACL writes pass true to changeAcl negative mode' +); +afs_test_contains($aclSubmit, '$aclBatches', 'ACL entries are batched by positive/negative set'); + +$aclUi = afs_test_section($manager, '// Edit AFS ACLs', '// --- TINYFILEMANAGER MAIN ---', 'AFS ACL editor'); +afs_test_contains($aclUi, 'fm_read_afs_acl($file_path)', 'ACL editor reads the current AFS ACL'); +afs_test_contains($aclUi, "!empty(\$mode['inherited'])", 'ACL editor detects inherited AuriStor ACLs'); +afs_test_contains($aclUi, "", + 'unreadable and inherited ACL controls are disabled'); + +$rights = array('l', 'r', 'w', 'i', 'd', 'k', 'a', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'); +$aclTypes = array('normal', 'negative'); +foreach ($aclTypes as $aclType) { + foreach ($rights as $right) { + $pattern = '/name="' . preg_quote($aclType, '/') . '\[[^"]+\]\[' . preg_quote($right, '/') . '\]"/'; + afs_test_ok( + preg_match($pattern, $aclUi) === 1, + $aclType . ' ACL editor exposes the ' . $right . ' right' + ); + } +} + +afs_test_ok( + preg_match('/name="normal\[[^"]+\]\[acl\]"/', $aclUi) === 1, + 'positive ACL rows post an empty-rights sentinel' +); +afs_test_ok( + preg_match('/name="negative\[[^"]+\]\[acl\]"/', $aclUi) === 1, + 'negative ACL rows post an empty-rights sentinel' +); + +$lockRows = 0; +foreach (preg_split('/\r?\n/', $aclUi) as $line) { + if (strpos($line, '][k]"') === false) { + continue; + } + $lockRows++; + afs_test_ok( + strpos($line, "\$perms['k']") !== false && strpos($line, "\$perms['l']") === false, + 'lock checkbox state is derived from the k right' + ); +} +afs_test_ok($lockRows === 2, 'both positive and negative ACL tables contain one lock row'); + +// ACL reads, writes, and display access must use typed wrappers around the +// configured provider. +afs_test_contains( + $aclSubmit, + 'fm_read_afs_acl($aclPath)', + 'ACL mutation route re-reads through the typed provider wrapper' +); +afs_test_contains( + $aclSubmit, + 'fm_change_afs_acl_entries(', + 'ACL mutation route writes through the strict provider wrapper' +); +afs_test_contains( + $aclSubmit, + '$aclBatches[$setName], $aclPath, $negative)', + 'ACL mutation passes the mapped positive/negative mode to its wrapper' +); +afs_test_contains( + $aclUi, + 'fm_read_afs_acl($file_path)', + 'ACL editor reads through the typed provider wrapper' +); + +$aclReadWrapper = afs_test_section( + $manager, + 'function fm_read_afs_acl(', + 'function fm_change_afs_acl_entries(', + 'ACL-read provider wrapper' +); +afs_test_contains( + $aclReadWrapper, + '$provider->readAcl($path)', + 'ACL-read wrapper delegates to the configured provider' +); +afs_test_contains( + $aclReadWrapper, + "isset(\$acl['normal'], \$acl['negative'])", + 'ACL-read wrapper requires normal and negative result sets' +); +afs_test_contains( + $aclReadWrapper, + "is_array(\$acl['normal']) && is_array(\$acl['negative'])", + 'ACL-read wrapper validates both result-set types' +); + +$aclWriteWrapper = afs_test_section( + $manager, + 'function fm_change_afs_acl_entries(', + 'function fm_get_afs_acl_access(', + 'ACL-write provider wrapper' +); +afs_test_contains( + $aclWriteWrapper, + '$provider->changeAclEntries(', + 'ACL-write wrapper delegates to the configured provider' +); +afs_test_contains( + $aclWriteWrapper, + '$entries, $path, $negative) === true;', + 'ACL-write wrapper accepts only literal true provider success' +); + +$aclAccessWrapper = afs_test_section( + $manager, + 'function fm_get_afs_acl_access(', + 'function fm_resolve_existing_path(', + 'caller-access provider wrapper' +); +afs_test_contains( + $aclAccessWrapper, + '$provider->getACLAccess($path)', + 'caller-access wrapper delegates to the configured provider' +); +afs_test_contains( + $aclAccessWrapper, + "preg_match('/^[lrwidkaA-H]{0,15}$/', \$rights)", + 'caller-access wrapper validates all standard and auxiliary rights' +); + +$mainListing = afs_test_section( + $manager, + "/*************************** ACTIONS ***************************/\n\n// get current path", + '// upload form', + 'main AFS listing' +); +afs_test_contains( + $mainListing, + '$path = fm_resolve_existing_path($path, \'dir\');', + 'main listing resolves its directory through the provider wrapper' +); +afs_test_contains( + $mainListing, + '$objects = fm_afs_provider()->listDirectory($path);', + 'AFS listing obtains names from the provider' +); +afs_test_contains( + $mainListing, + '$info = fm_inspect_path($new_path, true);', + 'AFS listing obtains typed metadata through the provider wrapper' +); +afs_test_contains( + $mainListing, + '$objectInfo[$file] = $info;', + 'AFS listing retains provider metadata for rendering' +); + +$fileViewer = afs_test_section( + $manager, + '// file viewer', + '// file editor', + 'file-viewer route' +); +$fileEditor = afs_test_section( + $manager, + '// file editor', + '// chmod (not for Windows or AFS)', + 'file-editor route' +); +foreach (array( + 'viewer' => $fileViewer, + 'editor' => $fileEditor +) as $surface => $surfaceSource) { + afs_test_contains( + $surfaceSource, + 'fm_inspect_path($file_path)', + $surface . ' obtains metadata through the provider wrapper' + ); + afs_test_contains( + $surfaceSource, + 'fm_get_mime_type($file_path)', + $surface . ' obtains MIME through the provider wrapper' + ); + afs_test_contains( + $surfaceSource, + 'fm_read_file_contents($file_path)', + $surface . ' reads text through the provider wrapper' + ); +} + +// Online viewers, raw media/Open URLs, and generic archive code remain +// unreachable whenever AFS support is active. +afs_test_contains( + $manager, + '$online_viewer = false;', + 'AFS mode disables the configured online-viewer variable after config.php' +); +afs_test_contains( + $manager, + "if (\$afsSupport && defined('FM_DOC_VIEWER') && FM_DOC_VIEWER !== false)", + 'AFS startup rejects a pre-defined non-false online-viewer constant' +); +afs_test_contains( + $fileViewer, + 'if (!$afsSupport && $is_onlineViewer)', + 'online viewer rendering is guarded out of AFS mode' +); +afs_test_contains( + $fileViewer, + '', + 'raw Open action requires non-AFS mode and enabled direct links' +); +afs_test_contains( + $fileViewer, + 'if (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_image)', + 'raw image inspection requires non-AFS mode and enabled previews' +); +afs_test_contains( + $fileViewer, + '} elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_image) {', + 'raw image rendering requires non-AFS mode and enabled previews' +); +afs_test_contains( + $fileViewer, + '} elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_audio) {', + 'raw audio rendering requires non-AFS mode and enabled previews' +); +afs_test_contains( + $fileViewer, + '} elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_video) {', + 'raw video rendering requires non-AFS mode and enabled previews' +); +afs_test_contains( + $fileViewer, + '} elseif (!$afsSupport && ($ext == \'zip\' || $ext == \'tar\')) {', + 'archive inspection is guarded out of AFS mode' +); + +$archiveCapability = afs_test_section( + $manager, + 'function fm_afs_archives_supported(', + '/**' . "\n" . ' * Delete file or folder', + 'archive capability gate' +); +afs_test_contains( + $archiveCapability, + 'return !fm_is_afs_mode();', + 'generic archive support is unconditionally disabled in AFS mode' +); +afs_test_not_contains( + $manager, + '->archivesSupported(', + 'a provider capability cannot re-enable generic archive walkers' +); +$archiveCreate = $csrfSections['archive create']; +$archiveExtract = $csrfSections['archive extract']; +$archiveCreateGuard = strpos($archiveCreate, 'if (!fm_afs_archives_supported())'); +$archiveCreateMutation = strpos($archiveCreate, 'new FM_Zipper()'); +$archiveExtractGuard = strpos($archiveExtract, 'if (!fm_afs_archives_supported())'); +$archiveExtractMutation = strpos($archiveExtract, 'new FM_Zipper()'); +afs_test_ok( + $archiveCreateGuard !== false && $archiveCreateMutation !== false + && $archiveCreateGuard < $archiveCreateMutation, + 'archive-create rejection precedes generic archive construction' +); +afs_test_ok( + $archiveExtractGuard !== false && $archiveExtractMutation !== false + && $archiveExtractGuard < $archiveExtractMutation, + 'archive-extract rejection precedes generic extraction construction' +); +afs_test_contains( + $manager, + '', + 'bulk archive controls are hidden when AFS disables archives' +); + +// Constructing an Afs object used to shell out once, after which each listing +// row called getcalleraccess again. Keep exactly one subprocess per item. +$constructor = afs_test_section($afs, 'public function __construct', 'public function getType', 'Afs constructor'); +afs_test_ok( + substr_count($constructor, 'getACLAccess(') === 0, + 'Afs construction does not perform an implicit getcalleraccess query' +); + +$folderListing = afs_test_section($manager, '$ii = 3399;', '$ik = 8002;', 'folder-listing loop'); +$fileListing = afs_test_section($manager, '$ik = 8002;', 'if (empty($folders) && empty($files))', 'file-listing loop'); +afs_test_ok(substr_count($folderListing, 'fm_get_afs_acl_access(') === 1, 'each folder row has one explicit getcalleraccess wrapper call'); +afs_test_ok(substr_count($fileListing, 'fm_get_afs_acl_access(') === 1, 'each file row has one explicit getcalleraccess wrapper call'); +afs_test_ok(substr_count($manager, 'fm_get_afs_acl_access(') === 3, 'Tiny File Manager has one wrapper definition and only two per-row callers'); + +afs_test_contains( + $fileViewer, + "\$file_url = \$afsSupport\n ? FM_SELF_URL", + 'AFS viewer builds its action URL from the relative controller' +); +afs_test_contains( + $fileEditor, + "\$file_url = \$afsSupport\n ? FM_SELF_URL", + 'AFS editor builds its save URL from the relative controller' +); +afs_test_contains( + $folderListing, + '', + 'AFS folder DirectLink is gated by the production-disabled flag' +); +afs_test_contains( + $folderListing, + 'href="?p="', + 'any explicitly enabled AFS folder DirectLink remains PHP-mediated navigation' +); +afs_test_contains( + $fileListing, + '', + 'AFS file DirectLink is gated by the production-disabled flag' +); +afs_test_contains( + $fileListing, + '&view=', + 'any explicitly enabled AFS file DirectLink remains a PHP-mediated view' +); +afs_test_contains( + $fileListing, + 'if (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && in_array(', + 'raw hover-image URLs require non-AFS mode and enabled previews' +); + +echo "SUMMARY: " . $afsTestPasses . " passed, " . count($afsTestFailures) . " failed\n"; +if (!empty($afsTestFailures)) { + exit(1); +} + +exit(0); diff --git a/tests/fixtures/auristor-listacl-with-volume-acl.txt b/tests/fixtures/auristor-listacl-with-volume-acl.txt new file mode 100644 index 00000000..dc5debd5 --- /dev/null +++ b/tests/fixtures/auristor-listacl-with-volume-acl.txt @@ -0,0 +1,12 @@ +Access list for /afs/example is +Normal rights: + system:anyuser rl + alice rlidwkaABCDEFGH +Negative rights: + blocked lk +Volume access list for example.volume is +Normal rights: + system:anyuser rl + compliance A +Negative rights: + blocked r diff --git a/tinyfilemanager.php b/tinyfilemanager.php index 4edb2bbc..afcf89d2 100644 --- a/tinyfilemanager.php +++ b/tinyfilemanager.php @@ -163,6 +163,53 @@ 'htaccess' => 'apache_conf', ); +// Proxy for URL Download Support (hostname:port) +// Note: configure the proxy for the URLs the server is allowed to reach. +//$proxyServer = 'proxy.url.tld:8080'; + +// OpenAFS / AuriStor support. Enable this in config.php on an AFS-backed host. +$afsSupport = false; + +// AFS production uses authentication performed by the reviewed web-server +// lane. Local Tiny File Manager credentials must be removed, not merely +// bypassed, before the immutable production profile can activate. +$afs_external_auth = false; + +// Feature switches retain upstream behavior by default. The immutable AFS +// production profile requires all five to be disabled. +$settings_enabled = true; +$direct_links_enabled = true; +$raw_previews_enabled = true; +$url_upload_enabled = true; + +// AFS production mode requires a native descriptor-backed provider. The +// bundled PHP AfsDataPlane is an offline/path-policy preview and deliberately +// reports itself as not production-ready. +$afsDataPlaneFactory = null; +$afs_expected_factory_class = ''; +$afs_expected_factory_id = ''; +$afs_expected_provider_class = ''; +$afs_expected_provider_id = ''; + +// Optional raw-tag overrides for non-AFS deployments. +$external_resources = array(); + +// AFS production assets come from one versioned JSON artifact consumed by +// both the application and container lock. It binds the ten logical resource +// keys to type, local path, SHA-256, license, and optional defer state. +$afs_asset_manifest_file = ''; +$afs_asset_manifest_sha256 = ''; +$external_asset_root = __DIR__; +$favicon_sha256 = ''; +$content_security_policy = ''; +$content_security_policy_approved = false; + +// The provider contract has no runtime or AFS side effects. Loading it before +// config.php lets a production configuration construct its reviewed factory. +if (is_readable(__DIR__ . '/afs_contract.php')) { + require_once __DIR__ . '/afs_contract.php'; +} + // if User has the external config file, try to use it to override the default config above [config.php] // sample config - https://tinyfilemanager.github.io/config-sample.txt $config_file = __DIR__ . '/config.php'; @@ -170,6 +217,64 @@ @include($config_file); } +if (($afsSupport || defined('AFS_PRODUCTION_PROFILE')) + && !interface_exists('AfsDataPlaneProviderFactory', false)) { + fm_afs_readiness_error( + 'AFS production requires the packaged provider contract.'); +} + +if ($afsSupport || defined('AFS_PRODUCTION_PROFILE')) { + require_once __DIR__ . '/afs.php'; +} + +if ($afsSupport || defined('AFS_PRODUCTION_PROFILE')) { + $afsSelfUrl = isset($_SERVER['SCRIPT_NAME']) + ? $_SERVER['SCRIPT_NAME'] : (isset($_SERVER['PHP_SELF']) + ? $_SERVER['PHP_SELF'] : ''); + $afsRequestIdentity = isset($_SERVER['REMOTE_USER']) + ? $_SERVER['REMOTE_USER'] : ''; + $afsDataRoot = $root_path; + $afsProfileState = array( + 'profile' => defined('AFS_PRODUCTION_PROFILE') + ? AFS_PRODUCTION_PROFILE : null, + 'afs_enabled' => $afsSupport, + 'external_auth' => $afs_external_auth, + 'request_identity' => $afsRequestIdentity, + 'local_auth' => $use_auth, + 'local_users_empty' => is_array($auth_users) + && is_array($readonly_users) && is_array($directories_users) + && empty($auth_users) && empty($readonly_users) + && empty($directories_users), + 'settings_enabled' => $settings_enabled, + 'embed_enabled' => defined('FM_EMBED'), + 'direct_links_enabled' => $direct_links_enabled, + 'raw_previews_enabled' => $raw_previews_enabled, + 'url_upload_enabled' => $url_upload_enabled, + 'root_url' => $root_url, + 'self_url' => $afsSelfUrl, + 'data_root' => $afsDataRoot, + 'asset_manifest_sha256' => $afs_asset_manifest_sha256, + 'expected_factory_class' => $afs_expected_factory_class, + 'expected_factory_id' => $afs_expected_factory_id, + 'expected_provider_class' => $afs_expected_provider_class, + 'expected_provider_id' => $afs_expected_provider_id + ); + $afsProfileError = ''; + if (!AfsProductionReadiness::validateProductionProfile( + $afsProfileState, $afsProfileError)) { + fm_afs_readiness_error($afsProfileError); + } + if (defined('FM_ROOT_PATH') && FM_ROOT_PATH !== $afsDataRoot) { + fm_afs_readiness_error( + 'Pre-defined FM_ROOT_PATH does not match the production profile.'); + } + unset($afsProfileState, $afsProfileError); + + // Protected file URLs must never be delegated to an external document + // viewer in AFS mode, even if config.php requested one. + $online_viewer = false; +} + define('ACE_FONTSIZE', isset($ace_fontsize) ? $ace_fontsize : 12); define('ACE_THEME', isset($ace_theme) ? $ace_theme : 'textmate'); @@ -189,6 +294,57 @@ 'pre-cloudflare' => '' ); +$afsReadinessError = ''; +if ($afsSupport) { + $external = AfsProductionReadiness::buildLocalAssetTagsFromManifestFile( + $afs_asset_manifest_file, $external_asset_root, + $afs_asset_manifest_sha256, $afsReadinessError); + if ($external === false) { + fm_afs_readiness_error($afsReadinessError); + } +} elseif (is_array($external_resources) && !empty($external_resources)) { + $external = array_replace($external, $external_resources); +} + +if ($afsSupport && $favicon_path !== '' + && !AfsProductionReadiness::validateLocalAsset( + $favicon_path, $external_asset_root, $favicon_sha256, + $afsReadinessError)) { + fm_afs_readiness_error($afsReadinessError); +} + +if ($afsSupport && !fm_content_security_policy_is_ready( + $content_security_policy, $afsReadinessError)) { + fm_afs_readiness_error($afsReadinessError); +} +if ($afsSupport && $content_security_policy_approved !== true) { + fm_afs_readiness_error( + 'AFS production mode requires explicit review approval for its CSP.'); +} +if ($content_security_policy !== '') { + if (!fm_content_security_policy_is_ready( + $content_security_policy, $afsReadinessError)) { + fm_afs_readiness_error('Invalid Content-Security-Policy configuration.'); + } + if ($afsSupport) { + foreach (headers_list() as $configuredHeader) { + if (stripos($configuredHeader, 'Content-Security-Policy:') === 0) { + fm_afs_readiness_error( + 'Duplicate Content-Security-Policy response header.'); + } + } + } + // PHP is the sole CSP source of truth. The container must not add a + // second policy; exact-image validation checks the rendered response. + header('Content-Security-Policy: ' . $content_security_policy, true); +} +if ($afsSupport + && AfsProductionReadiness::applicationTemplatesSupportStrictCsp() + !== true) { + fm_afs_readiness_error( + 'AFS production mode requires nonce/hash CSP template support.'); +} + // --- EDIT BELOW CAREFULLY OR DO NOT EDIT AT ALL --- // max upload file size @@ -292,12 +448,38 @@ function session_error_handling_function($code, $msg, $file, $line) // clean $root_url $root_url = fm_clean_path($root_url); -// abs path for site -defined('FM_ROOT_URL') || define('FM_ROOT_URL', ($is_https ? 'https' : 'http') . '://' . $http_host . (!empty($root_url) ? '/' . $root_url : '')); -defined('FM_SELF_URL') || define('FM_SELF_URL', ($is_https ? 'https' : 'http') . '://' . $http_host . $_SERVER['PHP_SELF']); +// abs path for site. AFS mode uses a same-origin relative controller URL and +// deliberately has no raw managed-root URL; it never trusts the request Host +// or forwarded protocol for protected redirects and links. +if ($afsSupport) { + if (!is_string($afsSelfUrl) || $afsSelfUrl === '' + || substr($afsSelfUrl, 0, 1) !== '/' + || strpos($afsSelfUrl, "\0") !== false + || preg_match('/[\r\n?#]/', $afsSelfUrl) + || strpos($afsSelfUrl, '//') !== false) { + fm_afs_readiness_error('Invalid same-origin controller path.'); + } + if (defined('FM_ROOT_URL') && FM_ROOT_URL !== '') { + fm_afs_readiness_error('AFS mode forbids a raw FM_ROOT_URL.'); + } + if (defined('FM_SELF_URL') && FM_SELF_URL !== $afsSelfUrl) { + fm_afs_readiness_error( + 'AFS mode requires the same-origin controller path.'); + } + defined('FM_ROOT_URL') || define('FM_ROOT_URL', ''); + defined('FM_SELF_URL') || define('FM_SELF_URL', $afsSelfUrl); +} else { + defined('FM_ROOT_URL') || define('FM_ROOT_URL', ($is_https ? 'https' : 'http') . '://' . $http_host . (!empty($root_url) ? '/' . $root_url : '')); + defined('FM_SELF_URL') || define('FM_SELF_URL', ($is_https ? 'https' : 'http') . '://' . $http_host . $_SERVER['PHP_SELF']); +} // logout -if (isset($_GET['logout'])) { +if (isset($_POST['logout'])) { + if (!isset($_POST['token']) || !is_string($_POST['token']) + || !verifyToken($_POST['token'])) { + header('HTTP/1.1 403 Forbidden'); + die('Invalid Token.'); + } unset($_SESSION[FM_SESSION_ID]['logged']); unset($_SESSION['token']); fm_redirect(FM_SELF_URL); @@ -437,22 +619,108 @@ function getClientIP() } // clean and check $root_path +if ($afsSupport) { + // Use the single post-config profile snapshot; no later request or + // per-user state may select a different provider root. + $root_path = $afsDataRoot; +} +if (!is_string($root_path)) { + if ($afsSupport) { + fm_afs_readiness_error('AFS root path must be a string.'); + } + die('Invalid root path configuration.'); +} $root_path = rtrim($root_path, '\\/'); $root_path = str_replace('\\', '/', $root_path); -if (!@is_dir($root_path)) { +if (!$afsSupport && !@is_dir($root_path)) { echo "

" . lng('Root path') . " \"{$root_path}\" " . lng('not found!') . "

"; exit; } +if ($afsSupport && (!is_string($root_path) + || substr($root_path, 0, 1) !== '/' + || strpos($root_path, "\0") !== false)) { + fm_afs_readiness_error('AFS root path must be an absolute pathname.'); +} defined('FM_SHOW_HIDDEN') || define('FM_SHOW_HIDDEN', $show_hidden_files); defined('FM_ROOT_PATH') || define('FM_ROOT_PATH', $root_path); +if ($afsSupport && FM_ROOT_PATH !== $afsDataRoot) { + fm_afs_readiness_error( + 'FM_ROOT_PATH did not remain bound to the production profile.'); +} defined('FM_LANG') || define('FM_LANG', $lang); defined('FM_FILE_EXTENSION') || define('FM_FILE_EXTENSION', $allowed_file_extensions); defined('FM_UPLOAD_EXTENSION') || define('FM_UPLOAD_EXTENSION', $allowed_upload_extensions); defined('FM_EXCLUDE_ITEMS') || define('FM_EXCLUDE_ITEMS', (version_compare(PHP_VERSION, '7.0.0', '<') ? serialize($exclude_items) : $exclude_items)); +if ($afsSupport && defined('FM_DOC_VIEWER') && FM_DOC_VIEWER !== false) { + fm_afs_readiness_error( + 'AFS production mode requires FM_DOC_VIEWER to be false.'); +} defined('FM_DOC_VIEWER') || define('FM_DOC_VIEWER', $online_viewer); define('FM_READONLY', $global_readonly || ($use_auth && !empty($readonly_users) && isset($_SESSION[FM_SESSION_ID]['logged']) && in_array($_SESSION[FM_SESSION_ID]['logged'], $readonly_users))); define('FM_IS_WIN', DIRECTORY_SEPARATOR == '\\'); +if ($afsSupport && ((defined('FM_SETTINGS_ENABLED') + && FM_SETTINGS_ENABLED !== false) + || (defined('FM_DIRECT_LINKS_ENABLED') + && FM_DIRECT_LINKS_ENABLED !== false) + || (defined('FM_RAW_PREVIEWS_ENABLED') + && FM_RAW_PREVIEWS_ENABLED !== false) + || (defined('FM_URL_UPLOAD_ENABLED') + && FM_URL_UPLOAD_ENABLED !== false))) { + fm_afs_readiness_error( + 'AFS production feature constants must remain disabled.'); +} +defined('FM_SETTINGS_ENABLED') || define('FM_SETTINGS_ENABLED', $settings_enabled); +defined('FM_DIRECT_LINKS_ENABLED') || define('FM_DIRECT_LINKS_ENABLED', $direct_links_enabled); +defined('FM_RAW_PREVIEWS_ENABLED') || define('FM_RAW_PREVIEWS_ENABLED', $raw_previews_enabled); +defined('FM_URL_UPLOAD_ENABLED') || define('FM_URL_UPLOAD_ENABLED', $url_upload_enabled); +if ($afsSupport && (FM_SETTINGS_ENABLED !== false + || FM_DIRECT_LINKS_ENABLED !== false + || FM_RAW_PREVIEWS_ENABLED !== false + || FM_URL_UPLOAD_ENABLED !== false)) { + fm_afs_readiness_error( + 'AFS production features did not remain fail-closed.'); +} + +$afsDataPlane = null; +if ($afsSupport) { + if (!($afsDataPlaneFactory instanceof AfsDataPlaneProviderFactory)) { + fm_afs_readiness_error( + 'AFS production mode requires an AfsDataPlaneProviderFactory.'); + } + if (get_class($afsDataPlaneFactory) !== $afs_expected_factory_class + || $afsDataPlaneFactory->getFactoryIdentity() + !== $afs_expected_factory_id) { + fm_afs_readiness_error( + 'The configured AFS factory identity does not match the profile.'); + } + $afsDataPlane = $afsDataPlaneFactory->createProvider( + FM_ROOT_PATH, $afsRequestIdentity); + if (!($afsDataPlane instanceof AfsDataPlaneProvider)) { + fm_afs_readiness_error( + 'The configured AFS factory did not return an AfsDataPlaneProvider.'); + } + if (get_class($afsDataPlane) !== $afs_expected_provider_class + || $afsDataPlane->getProviderIdentity() + !== $afs_expected_provider_id + || $afsDataPlane->getCredentialIdentity() + !== $afsRequestIdentity) { + fm_afs_readiness_error( + 'The AFS provider or credential identity does not match the profile.'); + } + if ($afsDataPlane->isProductionReady() !== true) { + fm_afs_readiness_error($afsDataPlane->getReadinessFailure()); + } + if ($afsDataPlane->getSecurityBoundary() + !== AfsDataPlaneProvider::SECURITY_BOUNDARY_DESCRIPTOR_BENEATH_V1) { + fm_afs_readiness_error( + 'AFS production mode requires the descriptor-beneath-v1 boundary.'); + } + if ($afsDataPlane->initializeDataPlane(FM_ROOT_PATH) !== true) { + fm_afs_readiness_error('Unable to initialize the AFS data-plane provider.'); + } + unset($afsRequestIdentity); +} // always use ?p= if (!isset($_GET['p']) && empty($_FILES)) { @@ -509,25 +777,23 @@ function getClientIP() $path .= '/' . FM_PATH; } // check path - if (!is_dir($path)) { + $path = fm_resolve_existing_path($path, 'dir'); + if ($path === false) { fm_redirect(FM_SELF_URL . '?p='); } $file = $_GET['edit']; $file = fm_clean_path($file); $file = str_replace('/', '', $file); - if ($file == '' || !is_file($path . '/' . $file)) { + $file_path = $file == '' ? false + : fm_resolve_existing_path($path . '/' . $file, 'file'); + if ($file_path === false) { fm_set_msg(lng('File not found'), 'error'); $FM_PATH = FM_PATH; fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); } header('X-XSS-Protection:0'); - $file_path = $path . '/' . $file; - $writedata = $_POST['content']; - $fd = fopen($file_path, "w"); - $write_results = @fwrite($fd, $writedata); - fclose($fd); - if ($write_results === false) { + if (!fm_write_file_contents($file_path, $writedata)) { header("HTTP/1.1 500 Internal Server Error"); die("Could Not Write File! - Check Permissions / Ownership"); } @@ -546,10 +812,12 @@ function getClientIP() $newFileName = "{$fileName}-{$date}.bak"; $fullyQualifiedFileName = $fullPath . $fileName; try { - if (!file_exists($fullyQualifiedFileName)) { + if (fm_resolve_existing_path( + $fullyQualifiedFileName, 'file') === false) { throw new Exception("File {$fileName} not found"); } - if (copy($fullyQualifiedFileName, $fullPath . $newFileName)) { + if (fm_copy($fullyQualifiedFileName, + $fullPath . $newFileName, false)) { echo "Backup {$newFileName} created"; } else { throw new Exception("Could not copy file {$fileName}"); @@ -561,6 +829,10 @@ function getClientIP() // Save Config if (isset($_POST['type']) && $_POST['type'] == "settings") { + if (!FM_SETTINGS_ENABLED || fm_is_afs_mode()) { + header('HTTP/1.1 403 Forbidden'); + die('Runtime configuration changes are disabled.'); + } global $cfg, $lang, $report_errors, $show_hidden_files, $lang_list, $hide_Cols, $theme; $newLng = $_POST['js-language']; fm_get_translations([]); @@ -603,12 +875,25 @@ function getClientIP() // new password hash if (isset($_POST['type']) && $_POST['type'] == "pwdhash") { + if (!FM_SETTINGS_ENABLED) { + header('HTTP/1.1 403 Forbidden'); + die('Runtime configuration utilities are disabled.'); + } $res = isset($_POST['inputPassword2']) && !empty($_POST['inputPassword2']) ? password_hash($_POST['inputPassword2'], PASSWORD_DEFAULT) : ''; echo $res; } //upload using url - if (isset($_POST['type']) && $_POST['type'] == "upload" && !empty($_REQUEST["uploadurl"])) { + $urlUploadRequested = isset($_POST['type']) + && $_POST['type'] === 'upload' + && array_key_exists('uploadurl', $_REQUEST); + if ($urlUploadRequested && FM_URL_UPLOAD_ENABLED !== true) { + header('HTTP/1.1 403 Forbidden'); + echo json_encode(array('fail' => array( + 'message' => 'URL upload is disabled'))); + exit(); + } + if ($urlUploadRequested && !empty($_REQUEST['uploadurl'])) { $path = FM_ROOT_PATH; if (FM_PATH != '') { $path .= '/' . FM_PATH; @@ -656,6 +941,15 @@ function get_file_path() exit(); } + if (fm_is_afs_mode() + && fm_resolve_write_path( + strtok(get_file_path(), '?'), true) === false) { + @unlink($temp_file); + event_callback(array('fail' => array( + 'message' => 'AFS upload destination failed confinement'))); + exit(); + } + if (!$url) { $success = false; } else if ($use_curl) { @@ -674,7 +968,12 @@ function get_file_path() $fileinfo->size = $curl_info["size_download"]; $fileinfo->type = $curl_info["content_type"]; } else { - $ctx = stream_context_create(); + if (isset($proxyServer)) { + $opts = array('http' => array('proxy' => 'tcp://' . $proxyServer, 'request_fulluri' => true)); + $ctx = stream_context_create($opts); + } else { + $ctx = stream_context_create(); + } @$success = copy($url, $temp_file, $ctx); if (!$success) { $err = error_get_last(); @@ -682,13 +981,20 @@ function get_file_path() } if ($success) { - $success = rename($temp_file, strtok(get_file_path(), '?')); + $success = fm_import_file( + $temp_file, strtok(get_file_path(), '?'), true, false); + if ($success && file_exists($temp_file) && !@unlink($temp_file)) { + $success = false; + $err = array('message' => 'Unable to remove the URL-upload temporary file'); + } } if ($success) { event_callback(array("done" => $fileinfo)); } else { - unlink($temp_file); + if (file_exists($temp_file)) { + @unlink($temp_file); + } if (!$err) { $err = array("message" => "Invalid url parameter"); } @@ -706,7 +1012,8 @@ function get_file_path() if (FM_PATH != '') { $path .= '/' . FM_PATH; } - $is_dir = is_dir($path . '/' . $del); + $is_dir = fm_resolve_existing_path( + $path . '/' . $del, 'dir') !== false; if (fm_rdelete($path . '/' . $del)) { $msg = $is_dir ? lng('Folder') . ' %s ' . lng('Deleted') : lng('File') . ' %s ' . lng('Deleted'); fm_set_msg(sprintf($msg, fm_enc($del))); @@ -731,10 +1038,13 @@ function get_file_path() $path .= '/' . FM_PATH; } if ($type == "file") { - if (!file_exists($path . '/' . $new)) { + if (!fm_path_exists($path . '/' . $new, true)) { if (fm_is_valid_ext($new)) { - @fopen($path . '/' . $new, 'w') or die('Cannot open file: ' . $new); - fm_set_msg(sprintf(lng('File') . ' %s ' . lng('Created'), fm_enc($new))); + if (fm_create_file($path . '/' . $new)) { + fm_set_msg(sprintf(lng('File') . ' %s ' . lng('Created'), fm_enc($new))); + } else { + fm_set_msg(sprintf(lng('File') . ' %s ' . lng('not created'), fm_enc($new)), 'error'); + } } else { fm_set_msg(lng('File extension is not allowed'), 'error'); } @@ -758,9 +1068,20 @@ function get_file_path() } // Copy folder / file -if (isset($_GET['copy'], $_GET['finish']) && !FM_READONLY) { +// Complete a single copy/move with a token-verified POST. This upstream route +// previously mutated state from a GET request. +if (isset($_POST['copy'], $_POST['finish'], $_POST['token']) && !FM_READONLY) { + if (!is_string($_POST['token']) || !verifyToken($_POST['token'])) { + fm_set_msg(lng('Invalid Token.'), 'error'); + die('Invalid Token.'); + } + if (!is_string($_POST['copy']) || $_POST['finish'] !== '1') { + fm_set_msg(lng('Invalid file or folder name'), 'error'); + die('Invalid copy request.'); + } + // from - $copy = urldecode($_GET['copy']); + $copy = urldecode($_POST['copy']); $copy = fm_clean_path($copy); // empty path if ($copy == '') { @@ -777,8 +1098,7 @@ function get_file_path() } $dest .= '/' . basename($from); // move? - $move = isset($_GET['move']); - $move = fm_clean_path(urldecode($move)); + $move = isset($_POST['move']) && $_POST['move'] === '1'; // copy/move/duplicate if ($from != $dest) { $msg_from = trim(FM_PATH . '/' . basename($from), '/'); @@ -803,7 +1123,7 @@ function get_file_path() $msg_from = trim(FM_PATH . '/' . basename($from), '/'); $fn_parts = pathinfo($from); $extension_suffix = ''; - if (!is_dir($from)) { + if (fm_resolve_existing_path($from, 'dir') === false) { $extension_suffix = '.' . $fn_parts['extension']; } //Create new name for duplicate @@ -811,7 +1131,7 @@ function get_file_path() $loop_count = 0; $max_loop = 1000; // Check if a file with the duplicate name already exists, if so, make new name (edge case...) - while (file_exists($fn_duplicate) & $loop_count < $max_loop) { + while (fm_path_exists($fn_duplicate, true) && $loop_count < $max_loop) { $fn_parts = pathinfo($fn_duplicate); $fn_duplicate = $fn_parts['dirname'] . '/' . $fn_parts['filename'] . '-copy' . $extension_suffix; $loop_count++; @@ -853,7 +1173,7 @@ function get_file_path() $FM_PATH = FM_PATH; fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); } - if (!is_dir($copy_to_path)) { + if (fm_resolve_existing_path($copy_to_path, 'dir') === false) { if (!fm_mkdir($copy_to_path, true)) { fm_set_msg(lng('Unable to create destination folder'), 'error'); $FM_PATH = FM_PATH; @@ -953,14 +1273,16 @@ function get_file_path() } // Check if the file exists and is valid - if ($dl != '' && is_file($path . '/' . $dl)) { + $downloadPath = $dl == '' ? false + : fm_resolve_existing_path($path . '/' . $dl, 'file'); + if ($downloadPath !== false) { // Close the session to prevent session locking if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } // Call the download function - fm_download_file($path . '/' . $dl, $dl, 1024); // Download with a buffer size of 1024 bytes + fm_download_file($downloadPath, $dl, 1024); // Download with a buffer size of 1024 bytes exit; } else { // Handle the case where the file is not found @@ -1008,7 +1330,7 @@ function get_file_path() $ext = pathinfo($filename, PATHINFO_FILENAME) != '' ? strtolower(pathinfo($filename, PATHINFO_EXTENSION)) : ''; $isFileAllowed = ($allowed) ? in_array($ext, $allowed) : true; - if (!fm_isvalid_filename($filename) && !fm_isvalid_filename($fullPathInput)) { + if (!fm_isvalid_filename($filename) || !fm_isvalid_filename($fullPathInput)) { $response = array( 'status' => 'error', 'info' => "Invalid File name!", @@ -1018,89 +1340,69 @@ function get_file_path() } $targetPath = $path . $ds; - if (is_writable($targetPath)) { + $targetReady = fm_is_afs_mode() + ? fm_resolve_existing_path($path, 'dir') !== false + : is_writable($targetPath); + if ($targetReady) { $fullPath = $path . '/' . $fullPathInput; $folder = substr($fullPath, 0, strrpos($fullPath, "/")); - if (!is_dir($folder)) { - $old = umask(0); - mkdir($folder, 0777, true); - umask($old); + $folderExists = fm_is_afs_mode() + ? fm_resolve_existing_path($folder, 'dir') !== false + : is_dir($folder); + if (!$folderExists) { + fm_mkdir($folder, true); } - if (empty($f['file']['error']) && !empty($tmp_name) && $tmp_name != 'none' && $isFileAllowed) { + if (fm_resolve_existing_path($folder, 'dir') !== false + && empty($f['file']['error']) && !empty($tmp_name) + && $tmp_name != 'none' && $isFileAllowed) { if ($chunkTotal) { - $out = @fopen("{$fullPath}.part", $chunkIndex == 0 ? "wb" : "ab"); - if ($out) { - $in = @fopen($tmp_name, "rb"); - if ($in) { - if (PHP_VERSION_ID < 80009) { - // workaround https://bugs.php.net/bug.php?id=81145 - do { - for (;;) { - $buff = fread($in, 4096); - if ($buff === false || $buff === '') { - break; - } - fwrite($out, $buff); - } - } while (!feof($in)); - } else { - stream_copy_to_stream($in, $out); - } - $response = array( - 'status' => 'success', - 'info' => "file upload successful" - ); - } else { - $response = array( - 'status' => 'error', - 'info' => "failed to open output stream", - 'errorDetails' => error_get_last() - ); - } - @fclose($in); - @fclose($out); - @unlink($tmp_name); - - $response = array( - 'status' => 'success', - 'info' => "file upload successful" - ); - } else { - $response = array( - 'status' => 'error', - 'info' => "failed to open output stream" - ); + $partPath = "{$fullPath}.part"; + $ordered = $chunkIndex == 0 + || fm_resolve_existing_path($partPath, 'file') !== false; + $stored = $ordered && fm_import_file( + $tmp_name, $partPath, true, $chunkIndex != 0); + if ($stored && file_exists($tmp_name) && !@unlink($tmp_name)) { + $stored = false; } - if ($chunkIndex == $chunkTotal - 1) { - if (file_exists($fullPath)) { + if ($stored && $chunkIndex == $chunkTotal - 1) { + if (fm_resolve_existing_path($fullPath, 'file') !== false) { $ext_1 = $ext ? '.' . $ext : ''; - $fullPathTarget = $path . '/' . basename($fullPathInput, $ext_1) . '_' . date('ymdHis') . $ext_1; + $fullPathTarget = dirname($fullPath) . '/' + . basename($fullPathInput, $ext_1) . '_' + . date('ymdHis') . $ext_1; } else { $fullPathTarget = $fullPath; } - rename("{$fullPath}.part", $fullPathTarget); + $stored = fm_rename($partPath, $fullPathTarget) === true; } - } else if (move_uploaded_file($tmp_name, $fullPath)) { - // Be sure that the file has been uploaded - if (file_exists($fullPath)) { - $response = array( - 'status' => 'success', - 'info' => "file upload successful" - ); + + $response = $stored + ? array('status' => 'success', + 'info' => 'file upload successful') + : array('status' => 'error', + 'info' => 'failed to store or finalize upload chunk'); + } else { + if (fm_is_afs_mode()) { + $stored = fm_import_file( + $tmp_name, $fullPath, true, false); + if ($stored && file_exists($tmp_name) + && !@unlink($tmp_name)) { + $stored = false; + } } else { - $response = array( - 'status' => 'error', - 'info' => 'Couldn\'t upload the requested file.' - ); + $stored = move_uploaded_file($tmp_name, $fullPath); } - } else { - $response = array( - 'status' => 'error', - 'info' => "Error while uploading files. Uploaded files $uploads", - ); + + $stored = $stored + && fm_resolve_existing_path($fullPath, 'file') !== false; + $response = $stored + ? array('status' => 'success', + 'info' => 'file upload successful') + : array('status' => 'error', + 'info' => "Error while uploading files. Uploaded files $uploads"); } } } else { @@ -1159,6 +1461,12 @@ function get_file_path() die("Invalid Token."); } + if (!fm_afs_archives_supported()) { + fm_set_msg(lng('Operations with archives are not available'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + $path = FM_ROOT_PATH; $ext = 'zip'; if (FM_PATH != '') { @@ -1224,6 +1532,12 @@ function get_file_path() die("Invalid Token."); } + if (!fm_afs_archives_supported()) { + fm_set_msg(lng('Operations with archives are not available'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + $unzip = urldecode($_POST['unzip']); $unzip = fm_clean_path($unzip); $unzip = str_replace('/', '', $unzip); @@ -1287,8 +1601,8 @@ function get_file_path() fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); } -// Change Perms (not for Windows) -if (isset($_POST['chmod'], $_POST['token']) && !FM_READONLY && !FM_IS_WIN) { +// Change POSIX permissions (not for Windows or AFS) +if (!$afsSupport && isset($_POST['chmod'], $_POST['token']) && !FM_READONLY && !FM_IS_WIN) { if (!verifyToken($_POST['token'])) { fm_set_msg(lng("Invalid Token."), 'error'); @@ -1348,6 +1662,88 @@ function get_file_path() fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); } +// Change AFS ACLs (not for Windows) +if ($afsSupport && isset($_POST['chmod'], $_POST['token']) && !FM_READONLY && !FM_IS_WIN) { + if (!verifyToken($_POST['token'])) { + fm_set_msg(lng('Invalid Token.'), 'error'); + die('Invalid Token.'); + } + + $path = FM_ROOT_PATH; + if (FM_PATH != '') { + $path .= '/' . FM_PATH; + } + + $file = fm_clean_path($_POST['chmod']); + $file = str_replace('/', '', $file); + $aclPath = $file == '' ? false + : fm_resolve_existing_path($path . '/' . $file); + if ($aclPath === false) { + fm_set_msg(lng('File not found'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + + $ret = true; + $currentAcl = fm_read_afs_acl($aclPath); + if (!is_array($currentAcl)) { + fm_set_msg(lng('Unable to read the current AFS ACL'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + if (!empty($currentAcl['inherited'])) { + fm_set_msg(lng('Inherited AuriStor ACLs are read-only here'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + + $allowedRights = array('l', 'r', 'w', 'i', 'd', 'k', 'a', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'); + $aclSets = array('normal' => false, 'negative' => true); + if ((!isset($_POST['normal']) || !is_array($_POST['normal'])) + && (!isset($_POST['negative']) || !is_array($_POST['negative']))) { + fm_set_msg(lng('Permissions not changed'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + } + + $aclBatches = array('normal' => array(), 'negative' => array()); + foreach ($aclSets as $setName => $negative) { + if (!isset($_POST[$setName]) || !is_array($_POST[$setName])) { + continue; + } + + foreach ($_POST[$setName] as $user => $perms) { + if (!is_array($perms)) { + $ret = false; + continue; + } + + $newAcl = ''; + foreach ($allowedRights as $right) { + if (isset($perms[$right])) { + $newAcl .= $right; + } + } + $newAcl = $newAcl == '' ? 'none' : $newAcl; + $aclBatches[$setName][$user] = $newAcl; + } + + if (!empty($aclBatches[$setName])) { + $ret = fm_change_afs_acl_entries( + $aclBatches[$setName], $aclPath, $negative) && $ret; + } + } + + if (empty($aclBatches['normal']) && empty($aclBatches['negative'])) { + $ret = false; + } + + fm_set_msg(lng($ret ? 'Permissions changed' : 'Permissions not changed'), $ret ? 'ok' : 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); +} + /*************************** ACTIONS ***************************/ // get current path @@ -1357,16 +1753,30 @@ function get_file_path() } // check path -if (!is_dir($path)) { +$path = fm_resolve_existing_path($path, 'dir'); +if ($path === false) { fm_redirect(FM_SELF_URL . '?p='); } // get parent folder $parent = fm_get_parent_path(FM_PATH); -$objects = is_readable($path) ? scandir($path) : array(); +if ($afsSupport) { + $objects = fm_afs_provider()->listDirectory($path); + $objects = is_array($objects) ? array_values(array_filter( + $objects, function ($item) { + return is_string($item) && $item !== '' + && $item !== '.' && $item !== '..' + && strpos($item, '/') === false + && strpos($item, '\\') === false + && strpos($item, "\0") === false; + })) : array(); +} else { + $objects = is_readable($path) ? scandir($path) : array(); +} $folders = array(); $files = array(); +$objectInfo = array(); $current_path = array_slice(explode("/", $path), -1)[0]; if (is_array($objects) && fm_is_exclude_items($current_path, $path)) { foreach ($objects as $file) { @@ -1377,9 +1787,21 @@ function get_file_path() continue; } $new_path = $path . '/' . $file; - if (@is_file($new_path) && fm_is_exclude_items($file, $new_path)) { + if ($afsSupport) { + $info = fm_inspect_path($new_path, true); + if ($info === false || !fm_is_exclude_items($file, $new_path)) { + continue; + } + $objectInfo[$file] = $info; + if ($info['type'] === 'dir') { + $folders[] = $file; + } elseif ($info['type'] === 'file' || $info['type'] === 'link') { + $files[] = $file; + } + } elseif (@is_file($new_path) && fm_is_exclude_items($file, $new_path)) { $files[] = $file; - } elseif (@is_dir($new_path) && $file != '.' && $file != '..' && fm_is_exclude_items($file, $new_path)) { + } elseif (@is_dir($new_path) && $file != '.' && $file != '..' + && fm_is_exclude_items($file, $new_path)) { $folders[] = $file; } } @@ -1418,9 +1840,11 @@ function getUploadExt() + +
@@ -1438,6 +1862,7 @@ function getUploadExt()
+ + @@ -1548,7 +1974,8 @@ function getUploadExt() if (isset($_GET['copy']) && !isset($_GET['finish']) && !FM_READONLY) { $copy = $_GET['copy']; $copy = fm_clean_path($copy); - if ($copy == '' || !file_exists(FM_ROOT_PATH . '/' . $copy)) { + if ($copy == '' + || fm_resolve_existing_path(FM_ROOT_PATH . '/' . $copy) === false) { fm_set_msg(lng('File not found'), 'error'); $FM_PATH = FM_PATH; fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); @@ -1563,11 +1990,15 @@ function getUploadExt() Source path:
Destination folder:

-

- Copy   - Move   +

+ + + + +   +   Cancel -

+

    @@ -1803,7 +2251,7 @@ function getSelected($l)
  • :
  • :
  • -
  • :
  • +
  • :
  • :
  • :
  • ' . lng('Image size') . ': ' . (isset($image_size[0]) ? $image_size[0] : '0') . ' x ' . (isset($image_size[1]) ? $image_size[1] : '0') . ''; } @@ -1851,10 +2299,13 @@ function getSelected($l) Delete - + + +
    @@ -1883,7 +2334,7 @@ class="edit-file">
    '; } else if ($online_viewer == 'microsoft') { @@ -1904,15 +2355,15 @@ class="edit-file"> ' . lng('Error while fetching archive info') . '

    '; } - } elseif ($is_image) { + } elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_image) { // Image content if (in_array($ext, array('gif', 'jpg', 'jpeg', 'png', 'bmp', 'ico', 'svg', 'webp', 'avif'))) { echo '

    '; } - } elseif ($is_audio) { + } elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_audio) { // Audio content echo '

    '; - } elseif ($is_video) { + } elseif (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && $is_video) { // Video content echo '
    '; } elseif ($is_text) { @@ -1952,7 +2403,9 @@ class="edit-file"> @@ -2052,8 +2521,8 @@ class="edit-file"> +
    +
    +
    +
    +

    + + :
    +

    + +
    + +
    + + + + + + > + + + + + + + + + + + + + + + + + + + + + $perms) { $encoded_user = fm_enc($user); ?> + + + + + + + + + + + + + + + + + + + + + $perms) { $encoded_user = fm_enc($user); ?> + + + + + + + + + + + + + + + + + + + +
    /ABCDEFGH
    + +

    +   + +

    + +
    +
    +
    +
    @@ -2157,7 +2745,8 @@ class="edit-file"> - + + @@ -2173,24 +2762,29 @@ class="edit-file"> - + '?'); + $perms = is_array($info) + ? substr(decoct($info['mode']), -4) : '----'; + $owner = array('name' => '?'); $group = array('name' => '?'); - if (function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { + if ($afsSupport && !FM_IS_WIN && !$hide_Cols) { + $perms = fm_get_afs_acl_access($path . '/' . $f); + } elseif (!$afsSupport && function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { try { $owner_id = fileowner($path . '/' . $f); if ($owner_id != 0) { @@ -2221,7 +2815,7 @@ class="edit-file"> >
    - ' . readlink($path . '/' . $f) . '' : '') ?> + ' . fm_enc($info['link_target']) . '' : '') ?>
    "> @@ -2232,16 +2826,20 @@ class="edit-file"> - + - + - + + + + + '?'); + $perms = is_array($info) + ? substr(decoct($info['mode']), -4) : '----'; + $owner = array('name' => '?'); $group = array('name' => '?'); - if (function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { + if ($afsSupport && !$is_link && !FM_IS_WIN && !$hide_Cols) { + $perms = fm_get_afs_acl_access($path . '/' . $f); + } elseif (!$afsSupport && function_exists('posix_getpwuid') && function_exists('posix_getgrgid')) { try { $owner_id = fileowner($path . '/' . $f); if ($owner_id != 0) { @@ -2292,15 +2895,18 @@ class="edit-file"> >
    - - - - - - - - ' . readlink($path . '/' . $f) . '' : '') ?> + if (!$is_link) { + if (!$afsSupport && FM_RAW_PREVIEWS_ENABLED && in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), array('gif', 'jpg', 'jpeg', 'png', 'bmp', 'ico', 'svg', 'webp', 'avif'))) { + $imagePreview = fm_enc(FM_ROOT_URL . (FM_PATH != '' ? '/' . FM_PATH : '') . '/' . $f); + echo ''; + } else { + echo ''; + } + } + ?> + + + ' . fm_enc($info['link_target']) . '' : '') ?>
    "> @@ -2308,19 +2914,24 @@ class="edit-file"> - + - + - + + + + + + - - + - + - + ' . fm_get_filesize($all_files_size) . '' ?> ' . $num_files . '' ?> ' . $num_folders . '' ?> @@ -2359,10 +2970,12 @@ class="edit-file"> - - - - + + + + + +
    @@ -2411,6 +3024,250 @@ function verifyToken($token) return false; } +function fm_content_security_policy_is_ready($policy, &$error = null) +{ + if (class_exists('AfsProductionReadiness')) { + return AfsProductionReadiness::validateContentSecurityPolicy( + $policy, $error); + } + if (!is_string($policy) || trim($policy) === '' + || preg_match('/[\x00\r\n]/', $policy)) { + $error = 'Invalid Content-Security-Policy configuration.'; + return false; + } + return true; +} + +function fm_afs_readiness_error($message) +{ + if (!headers_sent()) { + header('HTTP/1.1 503 Service Unavailable'); + header('Content-Type: text/plain; charset=UTF-8'); + } + echo "AFS readiness failure: " . (string)$message; + exit; +} + +function fm_is_afs_mode() +{ + global $afsSupport; + return !empty($afsSupport); +} + +function fm_afs_provider() +{ + global $afsDataPlane; + return ($afsDataPlane instanceof AfsDataPlaneProvider) + ? $afsDataPlane : false; +} + +function fm_read_afs_acl($path) +{ + $provider = fm_afs_provider(); + $acl = $provider !== false ? $provider->readAcl($path) : false; + return is_array($acl) && isset($acl['normal'], $acl['negative']) + && is_array($acl['normal']) && is_array($acl['negative']) + ? $acl : false; +} + +function fm_change_afs_acl_entries($entries, $path, $negative = false) +{ + $provider = fm_afs_provider(); + return $provider !== false + && $provider->changeAclEntries( + $entries, $path, $negative) === true; +} + +function fm_get_afs_acl_access($path) +{ + $provider = fm_afs_provider(); + $rights = $provider !== false ? $provider->getACLAccess($path) : ''; + return is_string($rights) + && preg_match('/^[lrwidkaA-H]{0,15}$/', $rights) + ? $rights : ''; +} + +function fm_resolve_existing_path($path, $type = 'any') +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $resolved = $provider !== false + ? $provider->resolveExistingPath($path, $type) : false; + return is_string($resolved) && $resolved !== '' ? $resolved : false; + } + if (($type === 'file' && !is_file($path)) + || ($type === 'dir' && !is_dir($path)) + || ($type === 'any' && !is_file($path) && !is_dir($path))) { + return false; + } + return $path; +} + +function fm_resolve_write_path($path, $allowExisting = true) +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $resolved = $provider !== false + ? $provider->resolveWritePath($path, $allowExisting) : false; + return is_string($resolved) && $resolved !== '' ? $resolved : false; + } + return $path; +} + +function fm_inspect_path($path, $allowLinkObject = false) +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $info = $provider !== false + ? $provider->inspectPath($path, $allowLinkObject) : false; + if (!is_array($info) || !isset( + $info['path'], $info['type'], $info['size'], + $info['mtime'], $info['mode']) + || !is_string($info['path']) + || !in_array($info['type'], array('file', 'dir', 'link'), true) + || !is_numeric($info['size']) || !is_numeric($info['mtime']) + || !is_int($info['mode'])) { + return false; + } + if ($info['type'] === 'link' + && (!array_key_exists('link_target', $info) + || !is_string($info['link_target']))) { + return false; + } + return $info; + } + + $stat = $allowLinkObject ? @lstat($path) : @stat($path); + if (!is_array($stat) || !isset($stat['mode'])) { + return false; + } + $kind = $stat['mode'] & 0170000; + if ($kind === 0120000 && $allowLinkObject) { + $type = 'link'; + $target = @readlink($path); + if ($target === false) { + return false; + } + } elseif ($kind === 0040000) { + $type = 'dir'; + $target = false; + } elseif ($kind === 0100000) { + $type = 'file'; + $target = false; + } else { + return false; + } + return array( + 'path' => $path, + 'type' => $type, + 'size' => isset($stat['size']) ? $stat['size'] : 0, + 'mtime' => isset($stat['mtime']) ? $stat['mtime'] : 0, + 'mode' => $stat['mode'], + 'link_target' => $target + ); +} + +function fm_path_exists($path, $allowLinkObject = false) +{ + if (fm_is_afs_mode()) { + return fm_inspect_path($path, $allowLinkObject) !== false; + } + return file_exists($path); +} + +function fm_read_file_contents($path) +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $contents = $provider !== false + ? $provider->readContents($path) : false; + return is_string($contents) ? $contents : false; + } + return @file_get_contents($path); +} + +function fm_write_file_contents($path, $contents) +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + return $provider !== false + && $provider->writeFile($path, $contents) === true; + } + + $handle = @fopen($path, 'w'); + if ($handle === false) { + return false; + } + $length = strlen($contents); + $written = 0; + while ($written < $length) { + $bytes = @fwrite($handle, substr($contents, $written)); + if ($bytes === false || $bytes === 0) { + @fclose($handle); + return false; + } + $written += $bytes; + } + $ok = @fflush($handle); + if (!@fclose($handle)) { + $ok = false; + } + return $ok; +} + +function fm_create_file($path) +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + return $provider !== false && $provider->createFile($path) === true; + } + $handle = @fopen($path, 'w'); + return $handle !== false && @fclose($handle); +} + +function fm_import_file($source, $destination, $overwrite = true, + $append = false) +{ + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + return $provider !== false && $provider->importFile( + $source, $destination, $overwrite, $append) === true; + } + + if ($append) { + $input = @fopen($source, 'rb'); + $output = @fopen($destination, 'ab'); + if ($input === false || $output === false) { + if (is_resource($input)) @fclose($input); + if (is_resource($output)) @fclose($output); + return false; + } + $ok = stream_copy_to_stream($input, $output) !== false; + if (!@fclose($input)) { + $ok = false; + } + if (!@fflush($output)) { + $ok = false; + } + if (!@fclose($output)) { + $ok = false; + } + return $ok; + } + if (!$overwrite && file_exists($destination)) { + return false; + } + return @copy($source, $destination); +} + +function fm_afs_archives_supported() +{ + // No current provider API owns archive enumeration and extraction one + // entry at a time. Never let a capability flag re-enable generic archive + // helpers in AFS mode. + return !fm_is_afs_mode(); +} + /** * Delete file or folder (recursively) * @param string $path @@ -2418,6 +3275,11 @@ function verifyToken($token) */ function fm_rdelete($path) { + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + return $provider !== false && $provider->removePath($path) === true; + } + if (is_link($path)) { return unlink($path); } elseif (is_dir($path)) { @@ -2497,6 +3359,17 @@ function fm_rename($old, $new) { $isFileAllowed = fm_is_valid_ext($new); + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $info = fm_inspect_path($old, true); + if ($info === false + || ($info['type'] !== 'dir' && !$isFileAllowed)) { + return false; + } + $result = $provider->renamePath($old, $new); + return $result === true ? true : ($result === null ? null : false); + } + if (!is_dir($old)) { if (!$isFileAllowed) return false; } @@ -2514,6 +3387,12 @@ function fm_rename($old, $new) */ function fm_rcopy($path, $dest, $upd = true, $force = true) { + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + return $provider !== false + && $provider->copyPath($path, $dest, $upd, $force) === true; + } + if (!is_dir($path) && !is_file($path)) { return false; } @@ -2547,6 +3426,19 @@ function fm_rcopy($path, $dest, $upd = true, $force = true) */ function fm_mkdir($dir, $force) { + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + if ($provider === false) { + return false; + } + $existing = fm_resolve_existing_path($dir, 'dir'); + if ($existing !== false) { + return $dir; + } + // AFS mode never deletes an existing non-directory to satisfy force. + return $provider->makeDirectory($dir, true) === true; + } + if (file_exists($dir)) { if (is_dir($dir)) { return $dir; @@ -2567,6 +3459,12 @@ function fm_mkdir($dir, $force) */ function fm_copy($f1, $f2, $upd) { + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + return $provider !== false + && $provider->copyPath($f1, $f2, $upd, false) === true; + } + $time1 = filemtime($f1); if (file_exists($f2)) { $time2 = filemtime($f2); @@ -2588,6 +3486,14 @@ function fm_copy($f1, $f2, $upd) */ function fm_get_mime_type($file_path) { + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $mime = $provider !== false + ? $provider->detectMimeType($file_path) : false; + return is_string($mime) && $mime !== '' + ? $mime : 'application/octet-stream'; + } + if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file_path); @@ -2758,6 +3664,11 @@ function fm_get_translations($tr) */ function fm_get_size($file) { + if (fm_is_afs_mode()) { + $info = fm_inspect_path($file); + return is_array($info) ? $info['size'] : 0; + } + static $iswin = null; static $isdarwin = null; static $exec_works = null; @@ -3373,6 +4284,12 @@ function fm_get_file_mimes($extension) function scan($dir = '', $filter = '') { $path = FM_ROOT_PATH . '/' . $dir; + if (fm_is_afs_mode()) { + $provider = fm_afs_provider(); + $results = $provider !== false + ? $provider->searchFiles($path, $filter) : false; + return is_array($results) ? $results : null; + } if ($path) { $ite = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); $rii = new RegexIterator($ite, "/(" . $filter . ")/i"); @@ -3400,8 +4317,89 @@ function scan($dir = '', $filter = '') * instead of download prompt * https://stackoverflow.com/a/13821992/1164642 */ +function fm_afs_download_file($fileLocation, $fileName, $chunkSize = 1024) +{ + $provider = fm_afs_provider(); + $handle = $provider !== false ? $provider->openRead($fileLocation) : false; + $stat = is_resource($handle) ? @fstat($handle) : false; + if ($handle === false || !is_array($stat)) { + if (is_resource($handle)) @fclose($handle); + return false; + } + + $size = $stat['size']; + if ($size === 0) { + @fclose($handle); + fm_set_msg(lng('Zero byte file! Aborting download'), 'error'); + $FM_PATH = FM_PATH; + fm_redirect(FM_SELF_URL . '?p=' . urlencode($FM_PATH)); + return false; + } + + $extension = pathinfo($fileName, PATHINFO_EXTENSION); + $contentType = fm_get_file_mimes($extension); + if (is_array($contentType)) { + $contentType = implode(' ', $contentType); + } + + $start = 0; + $end = $size - 1; + $partial = false; + if (isset($_SERVER['HTTP_RANGE'])) { + if (!preg_match('/^bytes=([0-9]+)-([0-9]*)$/', + $_SERVER['HTTP_RANGE'], $matches)) { + @fclose($handle); + header('HTTP/1.1 416 Range Not Satisfiable'); + return false; + } + $start = (int)$matches[1]; + $end = $matches[2] === '' ? $end : (int)$matches[2]; + if ($start > $end || $end >= $size || @fseek($handle, $start) !== 0) { + @fclose($handle); + header('HTTP/1.1 416 Range Not Satisfiable'); + return false; + } + $partial = true; + } + + header('Content-Description: File Transfer'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Pragma: public'); + header('Content-Transfer-Encoding: binary'); + header("Content-Type: $contentType"); + header('Content-Disposition: attachment;filename="' . + str_replace(array("\r", "\n", '"'), '', $fileName) . '"'); + header('Accept-Ranges: bytes'); + header('Content-Length: ' . ($end - $start + 1)); + header("Content-Range: bytes $start-$end/$size"); + if ($partial) { + header('HTTP/1.1 206 Partial Content'); + } + + while (ob_get_level()) ob_end_clean(); + $remaining = $end - $start + 1; + $ok = true; + while ($remaining > 0 && !feof($handle)) { + $buffer = fread($handle, min($chunkSize, $remaining)); + if ($buffer === false || $buffer === '') { + $ok = false; + break; + } + echo $buffer; + $remaining -= strlen($buffer); + } + if ($remaining !== 0 || !@fclose($handle)) { + $ok = false; + } + return $ok && connection_status() == 0 && !connection_aborted(); +} + function fm_download_file($fileLocation, $fileName, $chunkSize = 1024) { + if (fm_is_afs_mode()) { + return fm_afs_download_file($fileLocation, $fileName, $chunkSize); + } if (connection_status() != 0) return (false); $extension = pathinfo($fileName, PATHINFO_EXTENSION); @@ -3717,11 +4715,20 @@ function __construct() die($msg); } if (is_array($data) && count($data)) $this->data = $data; - else $this->save(); + else { + if (fm_is_afs_mode()) { + fm_afs_readiness_error( + 'AFS production configuration is invalid and immutable.'); + } + $this->save(); + } } function save() { + if (fm_is_afs_mode()) { + return false; + } global $config_file; $fm_file = is_readable($config_file) ? $config_file : __FILE__; $var_name = '$CONFIG'; @@ -3808,15 +4815,18 @@ function fm_show_nav_path($path) - + @@ -5016,6 +6026,7 @@ function new_password_hash($this) { return false; } + // Upload files using URL @param {Object} function upload_from_url($this) { let form = $($this), @@ -5052,6 +6063,7 @@ function upload_from_url($this) { }); return false; } + // Search template function search_template(data) { @@ -5552,6 +6564,17 @@ function lng($txt) $tr['en']['DirectLink'] = 'Direct link'; $tr['en']['UploadingFiles'] = 'Upload Files'; $tr['en']['ChangePermissions'] = 'Change Permissions'; + $tr['en']['lookup'] = 'lookup'; + $tr['en']['read'] = 'read'; + $tr['en']['write'] = 'write'; + $tr['en']['insert'] = 'insert'; + $tr['en']['delete'] = 'delete'; + $tr['en']['lock'] = 'lock'; + $tr['en']['admin'] = 'admin'; + $tr['en']['normalRights'] = 'Normal Rights'; + $tr['en']['negativeRights'] = 'Negative Rights'; + $tr['en']['Unable to read the current AFS ACL'] = 'Unable to read the current AFS ACL'; + $tr['en']['Inherited AuriStor ACLs are read-only here'] = 'Inherited AuriStor ACLs are read-only here'; $tr['en']['Copying'] = 'Copying'; $tr['en']['CreateNewItem'] = 'Create New Item'; $tr['en']['Name'] = 'Name';