- |
+ |
' . fm_get_filesize($all_files_size) . '' ?>
' . $num_files . '' ?>
' . $num_folders . '' ?>
@@ -5561,6 +5695,15 @@ 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']['Copying'] = 'Copying';
$tr['en']['CreateNewItem'] = 'Create New Item';
$tr['en']['Name'] = 'Name';
From 7ea1040cd3d7c6c2b12c5949c8f4604bf72a87b0 Mon Sep 17 00:00:00 2001
From: "Karl A. Grindley"
Date: Mon, 17 Aug 2026 20:18:44 -0400
Subject: [PATCH 03/15] Harden AFS ACL and helper failure handling
---
afs.php | 458 +++++++++++++++++++++++++++++++-------------
tinyfilemanager.php | 112 +++++++++--
2 files changed, 426 insertions(+), 144 deletions(-)
diff --git a/afs.php b/afs.php
index baf04def..4af3fd2e 100644
--- a/afs.php
+++ b/afs.php
@@ -28,6 +28,7 @@ class Afs
{
protected $selectedItems;
protected $afsUtils = '/usr/bin';
+ protected $afsRoot = '/afs';
public $confirmMsg = '';
public $errorMsg = '';
public $notifyMsg = '';
@@ -36,6 +37,7 @@ class Afs
public $adminPriv = 0;
public $deletePriv = 0;
public $insertPriv = 0;
+ public $lockPriv = 0;
public $lookupPriv = 0;
public $readPriv = 0;
public $writePriv = 0;
@@ -46,37 +48,44 @@ class Afs
public $formKey = '';
private $uniqname = '';
protected $afsStat;
+ protected $afsAvailable = false;
+ protected $lastFsStatus = 0;
protected $newName = '';
+ protected $originPath = '';
protected $startCWD = '';
public function __construct( $path="" )
{
- $this->uniqname = $_SERVER['REMOTE_USER'];
+ $this->uniqname = isset( $_SERVER['REMOTE_USER'] )
+ ? $_SERVER['REMOTE_USER'] : '';
$this->startCWD = getcwd();
- $this->afsStat = stat('/afs/');
+ $this->afsStat = @stat( $this->afsRoot );
// Bug 2634811 Fixed: Make sure /afs isn't on the local filesystem
- $rootStat = stat( '/' );
+ $rootStat = @stat( '/' );
- if ( $this->afsStat['dev'] == $rootStat['dev'] ) {
- error_log( "/afs has same device ID as / " .
+ 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__ );
- //header( 'Location: /missinghomedir.php' );
- $this->errorMsg = 'Missing home directory.';
- return false;
+ $this->errorMsg = 'AFS is not mounted.';
+ return;
}
+ $this->afsAvailable = true;
+
// Bug 1975875 Fixed: Don't trim whitespaces from path
- $this->setPath( $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__ );
- //header( 'Location: /missinghomedir.php' );
$this->errorMsg = 'Missing home directory.';
- return false;
+ return;
}
$this->parPath = $Matches[1];
$this->filename = $Matches[2];
@@ -87,10 +96,6 @@ public function __construct( $path="" )
$this->formKey = $_SESSION['formKey'];
$this->sid = md5( uniqid( rand(), true ));
- ////$this->type = $this->getType();
-
- $this->processCommand();
- $this->getACLAccess( $this->path );
}
@@ -110,7 +115,9 @@ public function getType()
$type = @filetype( basename( $this->path ));
if ( $type == 'file' ) {
- $this->mimetype = Mime::getMimeType( basename( $this->path ));
+ $this->mimetype = function_exists( 'fm_get_mime_type' )
+ ? fm_get_mime_type( basename( $this->path ))
+ : 'application/octet-stream';
@chdir( $this->startCWD );
return $type;
} else {
@@ -208,7 +215,7 @@ public function createFolder()
return false;
}
- if ( !mkdir( trim( basename( $this->selectedItems )), 0644, true )) {
+ if ( !mkdir( trim( basename( $this->selectedItems )), 0755, true )) {
$this->errorMsg = 'Unable to create folder.';
@chdir( $this->startCWD );
return false;
@@ -243,7 +250,7 @@ public function removeFolder( $folderPath )
$itemPath = $folderPath . '/' . $item;
- if ( is_dir( $itemPath ) && !is_link( $itemPath )) {
+ if ( is_dir( $itemPath ) && !is_link( $itemPath )) {
if ( !$this->removeFolder( $itemPath )) {
@chdir( $this->startCWD );
return false;
@@ -334,7 +341,7 @@ public function afsRename()
if ( !$this->makePathAFSlocal( $this->path )) {
return false;
}
-
+
if ( is_link( basename( $this->selectedItems ))) {
$this->errorMsg = "Symbolic links cannot be renamed.";
@chdir( $this->startCWD );
@@ -350,8 +357,14 @@ public function afsRename()
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, '/afs' )) {
+ $newName, $this->afsRoot )) {
$this->errorMsg = 'Unable to rename this file or folder.';
@chdir( $this->startCWD );
return false;
@@ -378,7 +391,12 @@ function moveFiles()
$sourcePath = $this->originPath . '/' . $file;
$destPath = $this->path . '/' . $file;
- if ( !@filedrawers_rename( $sourcePath, $destPath, '/afs' )) {
+ 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;
}
@@ -423,6 +441,19 @@ function copyFiles()
*/
public function copy_dirs( $source, $target )
{
+ $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;
}
@@ -483,6 +514,10 @@ public function copy_dirs( $source, $target )
*/
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;
@@ -496,7 +531,7 @@ public function copy( $source, $dest )
return false;
}
- if ( !symlink( $target, $name )) {
+ if ( !symlink( $target, basename( $dest ))) {
@chdir( $this->startCWD );
return false;
}
@@ -512,32 +547,60 @@ public function copy( $source, $dest )
$sourceStat = fstat( $sourceHdl );
- if ( $sourceStat['dev'] != $this->afsStat['dev'] ) {
+ 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 );
- fwrite( $destHdl, $buffer );
+ 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 );
- @fclose( $destHdl );
+ if ( !@fclose( $destHdl )) {
+ $copied = false;
+ }
+
+ if ( !$copied ) {
+ @unlink( basename( $dest ));
+ }
@chdir( $this->startCWD );
- return true;
+ return $copied;
}
@@ -545,19 +608,31 @@ public function copy( $source, $dest )
// 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 ( $stat['dev'] == $this->afsStat['dev'] ) {
+ 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
@@ -567,23 +642,94 @@ function changeAcl($entity,
$recursive=false,
$negative=false )
{
- $entity = escapeshellarg( $entity );
- $rights = escapeshellarg( trim( $rights ));
- $path = ( $path ) ? $path : $this->path;
- $neg = ( $negative ) ? ' -negative' : '';
- $cmd = "$this->afsUtils/fs sa $neg " . escapeshellarg( $path ) .
- " $entity $rights";
- $cmdRecur = "find " . escapeshellarg( $path ) . " -type d -exec " .
- "$this->afsUtils/fs sa $neg {} $entity $rights \\;";
- $cmd = ( $recursive ) ? $cmdRecur : $cmd;
+ $path = ( $path ) ? $path : $this->path;
+ $path = $this->pathSecurity( $path );
+ $rights = trim( $rights );
- if ( !$path ) {
+ if ( !$path || empty( $entity )
+ || !preg_match( '/^(none|[lrwidkaA-H]{1,15})$/', $rights )) {
+ $this->errorMsg =
+ 'Warning: Invalid access control list request.';
return false;
}
- if ( strpos( shell_exec( $cmd . " 2>&1" ), 'fs:' ) !== 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.";
+ 'Warning: Unable to modify the access control list.';
return false;
}
@@ -594,96 +740,149 @@ function changeAcl($entity,
function readAcl( $path='' )
{
$path = ( $path ) ? $path : $this->path;
- $cmd = "$this->afsUtils/fs listacl " . escapeshellarg( $path );
- $result = shell_exec( $cmd . " 2>&1" );
- $rights = array( 'l', 'r', 'w', 'i', 'd', 'k', 'a' );
-
+ $path = $this->pathSecurity( $path );
if ( !$path ) {
return false;
}
- if ( preg_match( '/^fs:/', $result )) {
- $this->errorMsg =
- "Warning: Unable to read the access control list.";
+ $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;
}
- $result = preg_replace( "/(.*)is\n(.*)rights:\n/", "", $result );
- $result = explode( "\nNegative rights:\n", $result );
+ return $this->parseAclOutput( $result );
+ }
- if ( isset( $result[0] )) {
- $normal = explode( "\n", trim( $result[0] ));
- if ( is_array( $normal )) {
- foreach ( $normal as $item ) {
- $perm = explode( ' ', trim( $item ));
- $setRights = $perm[1];
- foreach ( $rights as $right ) {
- if ( strpos( $setRights, $right ) !== false ) {
- $result['normal'][$perm[0]][$right] = true;
- } else {
- $result['normal'][$perm[0]][$right] = false;
- }
- }
+ 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' => preg_match(
+ '/^Access list \(inherited\) for /mi', $result ) === 1
+ );
+ $section = '';
+ $sawHeader = false;
+ $sawNormal = 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 )) {
+ $sawHeader = true;
+ continue;
+ }
+ if ( preg_match( '/^Normal rights:$/i', $line )) {
+ $section = 'normal';
+ $sawNormal = true;
+ continue;
+ }
+ if ( preg_match( '/^Negative rights:$/i', $line )) {
+ if ( !$sawNormal ) {
+ return false;
}
+ $section = 'negative';
+ 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;
}
- }
- if ( isset( $result[1] )) {
- $negative = explode( "\n", trim( $result[1] ));
- if ( is_array( $negative )) {
- foreach ( $negative as $item ) {
- $perm = explode( ' ', trim( $item ));
- $setRights = $perm[1];
- foreach ( $rights as $right ) {
- if ( strpos( $setRights, $right ) !== false ) {
- $result['negative'][$perm[0]][$right] = true;
- } else {
- $result['negative'][$perm[0]][$right] = 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;
}
}
- return $result;
+ if ( !$sawHeader || !$sawNormal ) {
+ return false;
+ }
+
+ return $acl;
}
- function getACLAccess( $path )
+ function getACLAccess( $path )
{
- if ( empty( $path )) {
- return false;
+ $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 '';
}
- $cmd = "$this->afsUtils/fs getcalleraccess " . escapeshellarg( $path );
- $result = shell_exec( $cmd . " 2>&1" );
+ $result = $this->runFs( array( 'getcalleraccess', $path ));
+ if ( $result === false || $this->lastFsStatus !== 0 ) {
+ return '';
+ }
$acls = '';
- if ( preg_match( "/^Callers access to .* is (\w{1,7})$/",
+ if ( preg_match( '/^Callers access to .* is ([lrwidkaA-H]{1,15})$/m',
$result, $Matches )) {
- $acls = strtolower( $Matches[1] );
-
- if ( strpos( $acls, 'l' ) !== false ) {
- $this->lookupPriv = 1;
- if ( strpos( $acls, 'a' ) !== false ) {
- $this->adminPriv = 1;
- }
- if ( strpos( $acls, 'd' ) !== false ) {
- $this->deletePriv= 1;
- }
- if ( strpos( $acls, 'i' ) !== false ) {
- $this->insertPriv = 1;
- }
- if ( strpos( $acls, 'r' ) !== false ) {
- $this->readPriv = 1;
- }
- if ( strpos( $acls, 'w' ) !== false ) {
- $this->writePriv = 1;
- }
- }
+ $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.
@@ -699,7 +898,7 @@ public function get_foldercontents_js( $showHidden=false )
} else {
$path = $this->path;
}
-
+
if ( !$this->makePathAFSlocal( $path )) {
$this->errorMsg = "Unable to view: $this->path.";
return false;
@@ -805,9 +1004,10 @@ function escape_js( $string )
* check only. To avoid race conditions, other precaustions must be used.
* CAUTION: This method will be removed in the next major release.
*/
- private function pathSecurity( $path='' )
+ protected function pathSecurity( $path='' )
{
- if ( empty( $path )) {
+ if ( !$this->afsAvailable || empty( $path )
+ || !is_array( $this->afsStat )) {
return false;
}
@@ -826,25 +1026,30 @@ private function pathSecurity( $path='' )
}
// Remove the final / in the target path if it exists
- return preg_replace( '/\/$/', '', $path );
+ 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 ( $this->afsStat["dev"] != $stat["dev"] ) {
+ $stat = @stat( '.' );
+ if ( !is_array( $stat ) || $this->afsStat['dev'] != $stat['dev'] ) {
$this->errorMsg = "Path not in AFS";
@chdir( $this->startCWD );
return false;
}
-
+
return true;
}
@@ -872,27 +1077,20 @@ public function linkSafeFileExists( $path )
// Set the afs path used inside the class
function setPath( $path='' )
{
- if ( $path == '/afs' || $path == '/afs/' ) {
- $path = null;
- }
-
- if ( !empty( $path )) {
- if ( !( $this->path = $this->pathSecurity( $path ))) {
- // Can't give this warning due to the current filedrawers
- // design. This should be fixed in the next release.
- // If a user navigates to a path that doesn't exist, they will
- // continue to get the warning until the url changes
- //$this->errorMsg = "The specified path does not exist. ($path)"
- $this->path = null;
- }
+ $safePath = $this->pathSecurity( $path );
+ if ( !$safePath ) {
+ $this->path = '';
+ $this->errorMsg = 'Path not in AFS';
+ return false;
}
- // Make sure the specified path was accepted
- if ( empty( $this->path )) {
- //GetHomeDir( $this->uniqname, $this->path, $this->errorMsg );
- $this->path = $this->pathSecurity( $this->path );
- }
+ $this->path = $safePath;
+ return true;
+ }
+ public function isAvailable()
+ {
+ return $this->afsAvailable;
}
// Makes each piece of a file path clickable
@@ -943,6 +1141,7 @@ function get_js_declarations()
$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 );
@@ -956,12 +1155,11 @@ private function js_var( $varname, $contents )
{
$retstr = "";
$retstr .= "var $varname = " .
- ( is_string( $contents ) ?
+ ( is_string( $contents ) ?
"'" . $this->escape_js( $contents ) . "'"
- : $contents )
+ : $contents )
. ";\n";
return $retstr;
}
}
-
diff --git a/tinyfilemanager.php b/tinyfilemanager.php
index 090f678e..3e804c0c 100644
--- a/tinyfilemanager.php
+++ b/tinyfilemanager.php
@@ -1385,13 +1385,61 @@ function get_file_path()
}
$ret = true;
- if (isset($_POST['normal']) && is_array($_POST['normal'])) {
- foreach ($_POST['normal'] as $user => $perms) {
- $afs = new Afs($path . '/' . $file);
- unset($perms['acl']);
- $newAcl = empty($perms) ? 'none' : implode('', array_keys($perms));
- $ret = $ret && $afs->changeAcl($user, $newAcl, $path . '/' . $file);
+ $aclPath = $path . '/' . $file;
+ $afs = new Afs($aclPath);
+ $currentAcl = $afs->readAcl($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])) {
+ $changed = $afs->changeAclEntries(
+ $aclBatches[$setName], $aclPath, $negative);
+ $ret = $changed && $ret;
+ }
+ }
+
+ if (empty($aclBatches['normal']) && empty($aclBatches['negative'])) {
+ $ret = false;
}
fm_set_msg(lng($ret ? 'Permissions changed' : 'Permissions not changed'), $ret ? 'ok' : 'error');
@@ -2193,8 +2241,11 @@ class="edit-file"> readAcl($file_path);
- $normal_acl = is_array($mode) && isset($mode['normal']) ? $mode['normal'] : array();
- $negative_acl = is_array($mode) && isset($mode['negative']) ? $mode['negative'] : array();
+ $acl_readable = is_array($mode);
+ $normal_acl = $acl_readable && isset($mode['normal']) ? $mode['normal'] : array();
+ $negative_acl = $acl_readable && isset($mode['negative']) ? $mode['negative'] : array();
+ $acl_inherited = $acl_readable && !empty($mode['inherited']);
+ $acl_readonly = !$acl_readable || $acl_inherited;
?>
@@ -2204,10 +2255,16 @@ class="edit-file">
:
+
+
+
+
+
@@ -5704,6 +5786,8 @@ function lng($txt)
$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';
From ab4ca69009ecbb5a9bd73225d25f97065bcd60b9 Mon Sep 17 00:00:00 2001
From: "Karl A. Grindley"
Date: Mon, 17 Aug 2026 20:23:17 -0400
Subject: [PATCH 04/15] Add AFS rebase evidence and offline audits
---
docs/AFS_FEATURE_INVENTORY.md | 390 ++++++++++++++++++++++++++++++++++
docs/AFS_REBASE_NOTES.md | 164 ++++++++++++++
docs/LIVE_AFS_TEST_PLAN.md | 204 ++++++++++++++++++
tests/afs_io_path_audit.php | 380 +++++++++++++++++++++++++++++++++
tests/afs_regression.php | 241 +++++++++++++++++++++
tests/afs_static.php | 239 +++++++++++++++++++++
6 files changed, 1618 insertions(+)
create mode 100644 docs/AFS_FEATURE_INVENTORY.md
create mode 100644 docs/AFS_REBASE_NOTES.md
create mode 100644 docs/LIVE_AFS_TEST_PLAN.md
create mode 100644 tests/afs_io_path_audit.php
create mode 100644 tests/afs_regression.php
create mode 100644 tests/afs_static.php
diff --git a/docs/AFS_FEATURE_INVENTORY.md b/docs/AFS_FEATURE_INVENTORY.md
new file mode 100644
index 00000000..9a971fc8
--- /dev/null
+++ b/docs/AFS_FEATURE_INVENTORY.md
@@ -0,0 +1,390 @@
+# AFS Feature Inventory
+
+This document records the AFS-related behavior present at the pre-rebase fork tip
+`194b4d034e99e6ad20c99bb31ea512f12a9a916b`. It is an inventory, not a statement
+that the behavior is correct or has been validated against a live OpenAFS or
+AuriStor mount. Post-rebase fixes are deliberately not folded into this historical
+inventory; see `docs/AFS_REBASE_NOTES.md` for the replay mapping, hardening layer,
+test results, and current claim boundary.
+
+Evidence references below use the form `commit:file:line`. Line numbers refer to
+the old AFS tip so that the inventory remains stable after rebasing.
+
+## Classification
+
+- **Actively wired** means Tiny File Manager calls the code from its normal
+ request flow when `$afsSupport` is enabled.
+- **Latent helper** means `afs.php` contains an implementation, but Tiny File
+ Manager does not call it or produce the legacy form fields needed to reach it.
+- **Generic bypass** means Tiny File Manager continues to use its ordinary PHP
+ data-plane path without the AFS device and opened-handle checks in `afs.php`.
+
+This distinction is important: the old fork actively wires AFS ACL display and
+editing, but it does not route most file operations through the AFS-safe helper
+methods.
+
+## Provenance
+
+Fork repository: `https://github.com/karlg100/tinyfilemanager.git`
+
+Canonical upstream: `https://github.com/prasathmani/tinyfilemanager.git`
+
+The old AFS tip is exactly two fork-local commits after merge base
+`2f357ee3d524f1085a7ca2707776c0f33ef85835` (`Fix translation error (#349)`).
+The canonical upstream tip fetched for the rebase was
+`41491439a6b243c55502581e53fad20bc4c6e777`.
+
+| Commit | Subject | Fork-local behavior |
+| --- | --- | --- |
+| `da98b2aa88d9ba2df7c2d67578710faec4431c3e` | `added proxy support for URL downloads` | Adds optional `$proxyServer` configuration and an HTTP stream context for the non-cURL URL-upload path. It contains no AFS logic. |
+| `194b4d034e99e6ad20c99bb31ea512f12a9a916b` | `added AFS support` | Adds all 967 lines of `afs.php` and modifies Tiny File Manager to load it, display caller access, and read/write ACLs. It also contains unrelated local configuration and UI changes. |
+
+The added `afs.php` carries a University of Michigan copyright notice and says
+“See COPYRIGHT” (`194b4d0:afs.php:2-5`), but the old tree contains `LICENSE` and
+no `COPYRIGHT` file. That source/license provenance should be resolved before
+redistribution.
+
+## Actively wired AFS behavior
+
+### Bootstrap and platform assumptions
+
+- `$afsSupport` defaults to `true`, and Tiny File Manager conditionally loads
+ `afs.php` (`194b4d0:tinyfilemanager.php:134-146`).
+- Loading `afs.php` requires the PHP `posix` extension. If it is absent, the
+ include prints an error and terminates the request
+ (`194b4d0:afs.php:12-16`).
+- Tiny File Manager's configured root remains `$_SERVER['DOCUMENT_ROOT']`; AFS
+ enablement does not change it to `/afs` or require it to be within AFS
+ (`194b4d0:tinyfilemanager.php:56-62`).
+- Application login is disabled by default in the AFS commit
+ (`194b4d0:tinyfilemanager.php:20-30`). As a result, `FM_READONLY` is false for
+ this configuration. The code appears to assume external authentication and
+ filesystem enforcement, but does not establish either.
+- `Afs::__construct()` reads `$_SERVER['REMOTE_USER']` without checking that it
+ exists, records the initial working directory, stats `/afs/`, compares its
+ device with `/`, validates the supplied path, initializes legacy form state,
+ invokes the legacy command dispatcher, and calls `getACLAccess()`
+ (`194b4d0:afs.php:52-94`). `REMOTE_USER` is used only in error logging; it is
+ not used to acquire credentials.
+
+No `aklog`, `klog`, token, PAG, `setpag`, or equivalent caller-credential setup
+exists in either fork-local commit. `/usr/bin/fs` and all filesystem operations
+inherit the web-server process's effective credentials.
+
+### Permission display and `getcalleraccess`
+
+When AFS support is enabled and permission columns are visible, Tiny File
+Manager replaces the POSIX mode string with the output of
+`Afs::getACLAccess()` for both folders and files. It also removes the POSIX
+owner/group column and adjusts table colspans
+(`194b4d0:tinyfilemanager.php:1981-1986,2010-2049,2063-2124,2142-2156`).
+
+`Afs::getACLAccess($path)`:
+
+1. Runs `/usr/bin/fs getcalleraccess `.
+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..6ecead5d
--- /dev/null
+++ b/docs/AFS_REBASE_NOTES.md
@@ -0,0 +1,164 @@
+# 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 and without force-pushing.
+
+```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 | `a2df5e893041a3e18134299058f7aa74ccda96d9` |
+| Replayed AFS commit | `ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1` |
+| Post-rebase AFS hardening | `be98d299ec262e34bb2b759b7742c3dfc18bd3af` |
+
+The authoritative old-to-new mapping is:
+
+```text
+da98b2aa88d9ba2df7c2d67578710faec4431c3e -> a2df5e893041a3e18134299058f7aa74ccda96d9
+194b4d034e99e6ad20c99bb31ea512f12a9a916b -> ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1
+```
+
+The pre-rebase AFS tip is retained at:
+
+```text
+refs/heads/safety/afs-pre-rebase-194b4d0-20260817
+```
+
+Useful provenance checks are:
+
+```sh
+git range-diff --creation-factor=100 \
+ 2f357ee3d524f1085a7ca2707776c0f33ef85835..194b4d034e99e6ad20c99bb31ea512f12a9a916b \
+ 41491439a6b243c55502581e53fad20bc4c6e777..ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1
+
+git diff --exit-code \
+ 194b4d034e99e6ad20c99bb31ea512f12a9a916b:afs.php \
+ ed6cc370c4c6a908e9ffa9aa9d4c4b33be40a8a1: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 replay deliberately retains upstream's authentication-enabled default, global-readonly behavior, online-viewer default, current theme, and current date format.
+
+### 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 configured external resources. Do not downgrade or hard-code DataTables. |
+| 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 current mutation routes, including both POSIX and AFS permission changes;
+- 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 `ed6cc37` 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 `ed6cc37` so `git range-diff` continues to show what was replayed versus what was newly repaired.
+
+Commit `be98d299ec262e34bb2b759b7742c3dfc18bd3af` 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.
+
+The no-live-mount regression layer is intentionally separate as well:
+
+- `tests/afs_regression.php` exercises ACL parsing and command construction, case-sensitive auxiliary rights, inherited ACLs, caller-access flags, path/device rejection, handle-time copy/read checks, broken symlinks, and helper inventory without touching `/afs`.
+- `tests/afs_static.php` checks default-off/config ordering, conditional `__DIR__` loading, retained upstream CSRF/URL-upload/exclusion controls, normal and negative ACL handling, all 15 rights, inherited-ACL gates, `k` mapping, batching, and one `getcalleraccess` call per listed item.
+- `tests/afs_io_path_audit.php` inventories the generic endpoints. Until they are integrated, it records exact expected failures for save/backup, create, copy/duplicate, move/rename, delete, uploads, download/view/direct links, archives, symlink traversal, and mount-point traversal. An expected-failure audit must fail if the unsafe baseline changes unexpectedly; it must never silently convert an untested path into a pass.
+- Run PHP lint on `tinyfilemanager.php`, `afs.php`, and every PHP test, followed by all three focused suites and any available upstream checks.
+
+The focused suite passed under PHP 7.4 and PHP 8.3: 46 regression assertions, 124 static assertions, and 80 I/O-path checks. The I/O audit reports four protected primitives, two fail-closed gates, 18 intentional expected failures, and zero unexpected failures. The PHP 7.4 run used the official `php:7.4-cli-alpine` image at digest `sha256:0d67d81f60f4a400f1b68e3a41e910c98c5e08f49e515f6855561a0f24d37852`.
+
+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`.
+
+## Remaining compatibility blockers
+
+The hardening commit repairs the actively integrated ACL surface and several dormant helpers; it does not wire Tiny File Manager's generic data plane into those helpers. A blanket AFS/AuriStor compatibility claim therefore remains blocked by all 18 expected-failure routes in `tests/afs_io_path_audit.php`, including direct links, archive operations, symlink traversal, and mount-point traversal.
+
+Additional live-only blockers are web-worker token/PAG identity, real OpenAFS and AuriStor `fs` output, file-versus-directory ACL semantics, same- and cross-volume behavior, unavailable/read-only mounts, and writeback failures. 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.php`, so its image is ordinary default-off Tiny File Manager rather than an AFS-capable deployment artifact. Data-plane wiring and AFS container packaging should be developed on distinct follow-on branches so the historical replay and ACL hardening remain reviewable.
diff --git a/docs/LIVE_AFS_TEST_PLAN.md b/docs/LIVE_AFS_TEST_PLAN.md
new file mode 100644
index 00000000..cd4b987a
--- /dev/null
+++ b/docs/LIVE_AFS_TEST_PLAN.md
@@ -0,0 +1,204 @@
+# 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, or operations behave correctly across volume mount points.
+
+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.
+- 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, and relevant PHP limits;
+- 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 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;
+- 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 into the disposable web root. Back up the original test configuration, then set at least:
+
+```php
+$afsSupport = true;
+$root_path = '/afs/';
+```
+
+Do not assume the upstream Dockerfile is the candidate deployment: it copies only `tinyfilemanager.php` and omits `afs.php`. If a container is used, build or mount an explicitly reviewed AFS-capable artifact and record both source-file checksums.
+
+Keep application authentication enabled unless the production design explicitly delegates authentication to the front-end server. Restrict the endpoint by network policy as well. Begin with URL proxying unset. If proxy behavior is in scope, test it later with a dedicated restricted proxy and record its DNS, redirect, and egress policy.
+
+Before using the browser, run PHP lint and the no-live-mount suites against the deployed source. Confirm the page loads without PHP warnings and that AFS mode is actually enabled. Verify that disabling `$afsSupport` restores ordinary upstream behavior without loading `afs.php`.
+
+## 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.
+
+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
+
+Exercise each 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.
+
+| Area | Cases to execute | Required evidence |
+| --- | --- | --- |
+| Listing/navigation | root and nested navigation, parent link, hidden items, exclusions, search, large directory | HTTP result, displayed names/access, raw listing, timing, `fs` call count |
+| Create | new file, empty file, directory, nested directory, invalid/NUL/path-like name, existing target | status/message, type/mode/FID, no outside-root delta |
+| Edit/save | plain editor, ACE/AJAX save, empty content, large content, failed write, backup | before/after hash and length, CSRF result, absence of partial data |
+| Upload | single file, overwrite/collision, zero/large file, disallowed extension, nested folder upload, chunked upload and interrupted chunk cleanup | request/chunk log, final hash/FID, `.part` cleanup, destination confinement |
+| URL upload | direct HTTP(S), redirects, rejected loopback/port, configured restricted proxy, failed transfer and temp cleanup | application and proxy logs, resolved destination, final hash, no internal-network reachability |
+| View/download | text, binary, zero/large file, byte-range request, image/media preview, missing and denied file | status/headers, byte-for-byte hash, token behavior, session behavior |
+| Direct link | regular file and directory under each identity | web-server authorization result; record that PHP cannot confine or authorize a direct link |
+| Copy/duplicate | file/tree, existing target, same-directory duplicate, copy into a direct and deep descendant, large/partial-write case, quota/writeback failure, symlink cases | source/destination hashes/types/targets, error atomicity, partial cleanup, confinement |
+| Move/rename | file/tree, same volume, cross volume, existing target, symlink, denied destination | source/destination state, expected cross-volume error or documented fallback, no loss |
+| Delete | file, empty/non-empty tree, batch selection, symlink, broken link, mount point | exact removed objects, sentinel preservation, no traversal into target/mounted volume |
+| Archive create | zip/tar one and many files, nested tree, symlink, child volume, denied member | member list, hashes, omissions/errors, no unexpected traversal |
+| Archive extract | zip/tar normal, overwrite, `../`, absolute path, symlink entry, extraction through symlink or mount point | destination manifest and proof that both escape sentinels remain 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, direct link, copy, duplicate, move/rename, single delete, batch delete, archive create, and archive extraction. The required confinement result is:
+
+- an operation may act on the link itself where that is the documented intent;
+- no operation may follow a link outside `FM_ROOT_PATH` for read or write;
+- recursive operations must detect loops and must not traverse an outside target;
+- deletion of a link must not delete its target;
+- direct-link exposure must be blocked by web-server configuration or documented as unsupported, because the PHP AFS wrapper cannot mediate it.
+
+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.
+2. Compare ACL display and effective caller access on the parent, mount point, child-volume root, and descendants.
+3. Copy files and trees into and out of the child volume and compare hashes, ACL effects, 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 recursive copy, delete, and archive creation at the mount-point object. Confirm whether the operation treats it as a boundary or traverses it; traversal is permitted only when explicitly intended and the entire target volume is disposable.
+6. Exercise a read-only mount/volume. Writes must fail clearly and leave no partial files or stale upload chunks.
+7. Test a symlink to a child-volume path and a symlink to an AFS path outside the configured root.
+8. Remove or make the 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;
+- 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, correct `k` handling, CSRF rejection, and enforcement evidence;
+- every claimed I/O route tested in both allowed and denied cases with no unexplained partial state;
+- 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;
+- complete evidence and a verified rollback/teardown.
+
+If a generic endpoint remains intentionally unwired, report it as unsupported rather than converting its static expected failure into a compatibility pass.
diff --git a/tests/afs_io_path_audit.php b/tests/afs_io_path_audit.php
new file mode 100644
index 00000000..0b78da60
--- /dev/null
+++ b/tests/afs_io_path_audit.php
@@ -0,0 +1,380 @@
+ $start, $label . ' end marker is missing or reordered')) {
+ return '';
+ }
+
+ return substr($source, $start, $end - $start);
+}
+
+function afs_audit_unwired($source)
+{
+ $guardMarkers = array(
+ '$afsSupport',
+ 'new Afs',
+ 'makePathAFSlocal',
+ 'pathSecurity',
+ '->copy(',
+ '->copy_dirs(',
+ '->readfile(',
+ '->removeFolder(',
+ '->deleteFiles(',
+ '->moveFiles(',
+ '->afsRename('
+ );
+
+ foreach ($guardMarkers as $marker) {
+ if (strpos($source, $marker) !== false) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function afs_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 afs_audit_protected($name, $condition, $detail)
+{
+ global $auditProtected, $auditChecks, $auditFailures;
+ $auditChecks++;
+
+ if (!$condition) {
+ $auditFailures[] = $name . ' no longer matches its protected baseline';
+ afs_audit_result('FAIL', $name, 'protected baseline changed; review required');
+ return;
+ }
+
+ $auditProtected++;
+ afs_audit_result('PROTECTED', $name, $detail);
+}
+
+function afs_audit_fail_closed($name, $condition, $detail)
+{
+ global $auditFailClosed, $auditChecks, $auditFailures;
+ $auditChecks++;
+
+ if (!$condition) {
+ $auditFailures[] = $name . ' no longer matches its fail-closed baseline';
+ afs_audit_result('FAIL', $name, 'fail-closed baseline changed; review required');
+ return;
+ }
+
+ $auditFailClosed++;
+ afs_audit_result('FAIL-CLOSED', $name, $detail);
+}
+
+function afs_audit_xfail($name, $condition, $detail)
+{
+ global $auditExpectedFailures, $auditChecks, $auditFailures;
+ $auditChecks++;
+
+ if (!$condition) {
+ $auditFailures[] = $name . ' changed from its expected-gap baseline';
+ afs_audit_result('FAIL', $name, 'expected-gap baseline changed; inspect and reclassify');
+ return;
+ }
+
+ $auditExpectedFailures++;
+ afs_audit_result('XFAIL', $name, $detail);
+}
+
+echo "AFS I/O path audit\n";
+
+// Guarded primitives retained in afs.php. These checks do not imply that the
+// Tiny File Manager request handlers call them.
+$pathSecurity = afs_audit_section($afs, 'function pathSecurity', 'public function makePathAFSlocal', 'Afs::pathSecurity');
+$makeLocal = afs_audit_section($afs, 'public function makePathAFSlocal', '// Checks to see if there is a folder', 'Afs::makePathAFSlocal');
+$removeFolder = afs_audit_section($afs, 'public function removeFolder', 'public function deleteFiles', 'Afs::removeFolder');
+$copyDirs = afs_audit_section($afs, 'public function copy_dirs', 'public function copy(', 'Afs::copy_dirs');
+$copyPrimitive = afs_audit_section($afs, 'public function copy(', '// A AFS safe version of the PHP readfile', 'Afs::copy');
+$readPrimitive = afs_audit_section($afs, 'function readfile()', '// Change the ACL for a given path', 'Afs::readfile');
+
+afs_audit_fail_closed(
+ 'path acceptance on a non-AFS device',
+ strpos($pathSecurity, '@stat( $path )') !== false
+ && preg_match('/\$this->afsStat\[[\'\"]dev[\'\"]\]\s*!=\s*\$pathStat\[[\'\"]dev[\'\"]\]/', $pathSecurity) === 1
+ && strpos($pathSecurity, 'return false;') !== false,
+ 'pathSecurity rejects a path whose st_dev differs from /afs'
+);
+afs_audit_fail_closed(
+ 'directory-local operation on a non-AFS device',
+ strpos($makeLocal, '@chdir( $path )') !== false
+ && preg_match('/\$this->afsStat\[[\'\"]dev[\'\"]\]\s*!=\s*\$stat\[[\'\"]dev[\'\"]\]/', $makeLocal) === 1
+ && strpos($makeLocal, 'Path not in AFS') !== false
+ && strpos($makeLocal, '@chdir( $this->startCWD )') !== false
+ && strpos($makeLocal, 'return false;') !== false,
+ 'makePathAFSlocal restores cwd and rejects the device mismatch'
+);
+afs_audit_protected(
+ 'AFS file-copy primitive',
+ strpos($copyPrimitive, 'fstat( $sourceHdl )') !== false
+ && strpos($copyPrimitive, '$sourceStat[\'dev\'] != $this->afsStat[\'dev\']') !== false
+ && substr_count($copyPrimitive, 'makePathAFSlocal(') >= 2
+ && strpos($copyPrimitive, 'fopen( basename( $dest ), "xb" )') !== false,
+ 'source handle st_dev and destination directory are checked; overwrite is refused'
+);
+afs_audit_protected(
+ 'AFS file-read primitive',
+ strpos($readPrimitive, 'fstat( $handle )') !== false
+ && strpos($readPrimitive, '$stat[\'dev\'] == $this->afsStat[\'dev\']') !== false,
+ 'bytes are emitted only after checking the opened handle device'
+);
+afs_audit_protected(
+ 'AFS recursive delete helper',
+ strpos($removeFolder, 'makePathAFSlocal( $folderPath )') !== false
+ && strpos($removeFolder, '!is_link( $itemPath )') !== false
+ && strpos($removeFolder, '$this->removeFolder( $itemPath )') !== false,
+ 'each directory is rechecked and symlinks are unlinked rather than traversed'
+);
+afs_audit_protected(
+ 'AFS recursive copy helper',
+ substr_count($copyDirs, 'makePathAFSlocal(') >= 2
+ && strpos($copyPrimitive, 'if ( is_link( $source ))') !== false
+ && strpos($copyPrimitive, 'readlink( $name )') !== false,
+ 'source and destination directories are rechecked and links are reproduced'
+);
+
+// Snapshot the request handlers and generic filesystem helpers.
+$save = afs_audit_section($manager, '// save editor file', '// backup files', 'save route');
+$backup = afs_audit_section($manager, '// backup files', '// Save Config', 'backup route');
+$urlUpload = afs_audit_section($manager, '//upload using url', " exit();\n}", 'URL-upload route');
+$deleteRoute = afs_audit_section($manager, '// Delete file / folder', '// Create a new file/folder', 'single-delete route');
+$createRoute = afs_audit_section($manager, '// Create a new file/folder', '// Copy folder / file', 'create route');
+$copyRoute = afs_audit_section($manager, '// Copy folder / file', '// Mass copy files/ folders', 'single-copy route');
+$massCopyRoute = afs_audit_section($manager, '// Mass copy files/ folders', '// Rename', 'mass-copy route');
+$renameRoute = afs_audit_section($manager, '// Rename', '// Download', 'rename route');
+$downloadRoute = afs_audit_section($manager, '// Download', '// Upload', 'download route');
+$uploadRoute = afs_audit_section($manager, '// Upload', '// Mass deleting', 'upload route');
+$massDeleteRoute = afs_audit_section($manager, '// Mass deleting', '// Pack files zip, tar', 'mass-delete route');
+$archiveCreateRoute = afs_audit_section($manager, '// Pack files zip, tar', '// Unpack zip, tar', 'archive-create route');
+$archiveExtractRoute = afs_audit_section($manager, '// Unpack zip, tar', '// Change POSIX permissions', 'archive-extract route');
+$viewer = afs_audit_section($manager, '// file viewer', '// file editor', 'file-view route');
+$listing = afs_audit_section($manager, '// --- TINYFILEMANAGER MAIN ---', '// --- END HTML ---', 'main listing');
+$directLinks = afs_audit_matching_lines($listing, "lng('DirectLink')");
+
+$deleteHelper = afs_audit_section($manager, 'function fm_rdelete($path)', 'function fm_rchmod', 'fm_rdelete');
+$renameHelper = afs_audit_section($manager, 'function fm_rename($old, $new)', 'function fm_rcopy', 'fm_rename');
+$recursiveCopy = afs_audit_section($manager, 'function fm_rcopy($path, $dest', 'function fm_mkdir', 'fm_rcopy');
+$mkdirHelper = afs_audit_section($manager, 'function fm_mkdir($dir, $force)', 'function fm_copy', 'fm_mkdir');
+$copyHelper = afs_audit_section($manager, 'function fm_copy($f1, $f2, $upd)', 'function fm_get_mime_type', 'fm_copy');
+$downloadHelper = afs_audit_section($manager, 'function fm_download_file(', 'class FM_Zipper', 'fm_download_file');
+$archiveHelpers = afs_audit_section($manager, 'class FM_Zipper', '//--- Templates Functions ---', 'archive helper classes');
+
+afs_audit_xfail(
+ 'save/edit writes',
+ afs_audit_unwired($save)
+ && strpos($save, 'fopen($file_path, "w")') !== false
+ && strpos($save, '@fwrite($fd, $writedata)') !== false,
+ 'AJAX save writes the resolved path directly without an AFS handle/device guard'
+);
+afs_audit_xfail(
+ 'backup writes',
+ afs_audit_unwired($backup)
+ && strpos($backup, 'copy($fullyQualifiedFileName, $fullPath . $newFileName)') !== false,
+ 'backup uses PHP copy directly and can follow a same-root symlink outside AFS'
+);
+afs_audit_xfail(
+ 'file and directory creation',
+ afs_audit_unwired($createRoute)
+ && strpos($createRoute, "@fopen(\$path . '/' . \$new, 'w')") !== false
+ && strpos($createRoute, "fm_mkdir(\$path . '/' . \$new, false)") !== false
+ && afs_audit_unwired($mkdirHelper)
+ && strpos($mkdirHelper, 'mkdir($dir, 0777, true)') !== false,
+ 'create routes use fopen/mkdir without checking the target directory device'
+);
+afs_audit_xfail(
+ 'copy',
+ afs_audit_unwired($copyRoute)
+ && strpos($copyRoute, 'fm_rcopy($from, $dest)') !== false
+ && afs_audit_unwired($recursiveCopy)
+ && strpos($recursiveCopy, 'return fm_copy($path, $dest, $upd)') !== false
+ && afs_audit_unwired($copyHelper)
+ && strpos($copyHelper, 'copy($f1, $f2)') !== false,
+ 'copy dispatches through generic recursive PHP copy, not Afs::copy/copy_dirs'
+);
+afs_audit_xfail(
+ 'duplicate',
+ afs_audit_unwired($copyRoute)
+ && strpos($copyRoute, 'fm_rcopy($from, $fn_duplicate, False)') !== false,
+ 'same-directory duplicate uses the same unguarded fm_rcopy path'
+);
+afs_audit_xfail(
+ 'move',
+ afs_audit_unwired($copyRoute . $massCopyRoute)
+ && strpos($copyRoute, 'fm_rename($from, $dest)') !== false
+ && strpos($massCopyRoute, 'fm_rename($from, $dest)') !== false
+ && afs_audit_unwired($renameHelper)
+ && strpos($renameHelper, 'rename($old, $new)') !== false,
+ 'single and mass move use generic rename without source/destination device checks'
+);
+afs_audit_xfail(
+ 'rename',
+ afs_audit_unwired($renameRoute)
+ && strpos($renameRoute, "fm_rename(\$path . '/' . \$old, \$path . '/' . \$new)") !== false
+ && afs_audit_unwired($renameHelper),
+ 'rename is not routed through the AFS/filedrawers-safe primitive'
+);
+afs_audit_xfail(
+ 'single and bulk delete',
+ afs_audit_unwired($deleteRoute . $massDeleteRoute)
+ && strpos($deleteRoute, "fm_rdelete(\$path . '/' . \$del)") !== false
+ && strpos($massDeleteRoute, 'fm_rdelete($new_path)') !== false
+ && afs_audit_unwired($deleteHelper)
+ && strpos($deleteHelper, 'unlink($path)') !== false
+ && strpos($deleteHelper, 'rmdir($path)') !== false,
+ 'delete uses generic unlink/rmdir recursion rather than Afs::removeFolder/deleteFiles'
+);
+afs_audit_xfail(
+ 'single-part upload',
+ afs_audit_unwired($uploadRoute)
+ && strpos($uploadRoute, 'move_uploaded_file($tmp_name, $fullPath)') !== false,
+ 'the final uploaded-file destination is not device-checked'
+);
+afs_audit_xfail(
+ 'chunked upload',
+ afs_audit_unwired($uploadRoute)
+ && strpos($uploadRoute, '"{$fullPath}.part"') !== false
+ && strpos($uploadRoute, 'fopen("{$fullPath}.part"') !== false
+ && strpos($uploadRoute, 'rename("{$fullPath}.part", $fullPathTarget)') !== false,
+ 'chunk append and final rename operate directly on the requested path'
+);
+afs_audit_xfail(
+ 'URL upload',
+ afs_audit_unwired($urlUpload)
+ && strpos($urlUpload, 'copy($url, $temp_file, $ctx)') !== false
+ && strpos($urlUpload, "rename(\$temp_file, strtok(get_file_path(), '?'))") !== false,
+ 'SSRF checks are retained, but the final filesystem rename is not AFS-confined'
+);
+afs_audit_xfail(
+ 'download/read',
+ afs_audit_unwired($downloadRoute)
+ && strpos($downloadRoute, 'fm_download_file(') !== false
+ && afs_audit_unwired($downloadHelper)
+ && strpos($downloadHelper, 'realpath($fileLocation)') !== false
+ && strpos($downloadHelper, 'readfile($fileLocation)') !== false,
+ 'download follows realpath and calls PHP readfile instead of Afs::readfile'
+);
+afs_audit_xfail(
+ 'view/preview reads',
+ afs_audit_unwired($viewer)
+ && strpos($viewer, 'file_get_contents($file_path)') !== false
+ && strpos($viewer, 'is_file($path . \'/\' . $file)') !== false,
+ 'viewer follows file symlinks and reads without validating the opened handle device'
+);
+afs_audit_xfail(
+ 'direct links',
+ afs_audit_unwired($directLinks)
+ && substr_count($directLinks, "lng('DirectLink')") === 2
+ && substr_count($directLinks, 'FM_ROOT_URL') === 2,
+ 'links hand paths to the web server, bypassing PHP and every Afs guard'
+);
+afs_audit_xfail(
+ 'archive creation',
+ afs_audit_unwired($archiveCreateRoute . $archiveHelpers)
+ && strpos($archiveCreateRoute, 'new FM_Zipper()') !== false
+ && strpos($archiveCreateRoute, 'new FM_Zipper_Tar()') !== false
+ && strpos($archiveHelpers, 'addFile($filename)') !== false
+ && strpos($archiveHelpers, 'scandir($path)') !== false,
+ 'ZIP/TAR creation recursively reads paths without AFS device or symlink boundaries'
+);
+afs_audit_xfail(
+ 'archive extraction',
+ afs_audit_unwired($archiveExtractRoute . $archiveHelpers)
+ && strpos($archiveExtractRoute, 'extractTo($path, null, true)') !== false
+ && substr_count($archiveHelpers, 'extractTo($path)') >= 2,
+ 'ZIP/TAR extraction writes directly to a path without per-entry AFS confinement'
+);
+afs_audit_xfail(
+ 'symlink traversal',
+ afs_audit_unwired($deleteHelper . $recursiveCopy . $viewer . $directLinks)
+ && strpos($deleteHelper, 'if (is_link($path))') !== false
+ && strpos($deleteHelper, 'return unlink($path)') !== false
+ && strpos($recursiveCopy, 'if (is_dir($path))') !== false
+ && strpos($recursiveCopy, 'is_link(') === false
+ && strpos($viewer, 'file_get_contents($file_path)') !== false,
+ 'delete unlinks a link, but copy/view/direct-link paths can follow it outside AFS'
+);
+afs_audit_xfail(
+ 'mount-point traversal',
+ afs_audit_unwired($deleteHelper . $recursiveCopy . $archiveHelpers)
+ && strpos($deleteHelper, 'scandir($path)') !== false
+ && strpos($recursiveCopy, 'scandir($path)') !== false
+ && strpos($archiveHelpers, 'scandir($path)') !== false
+ && strpos($deleteHelper . $recursiveCopy . $archiveHelpers, 'lstat(') === false
+ && strpos($deleteHelper . $recursiveCopy . $archiveHelpers, "['dev']") === false,
+ 'generic recursion treats mount points as directories and never checks st_dev'
+);
+
+echo 'SUMMARY: ' . $auditProtected . ' protected, '
+ . $auditFailClosed . ' fail-closed, '
+ . $auditExpectedFailures . ' expected failures, '
+ . count($auditFailures) . ' unexpected failures across '
+ . $auditChecks . " checks\n";
+
+if (!empty($auditFailures)) {
+ exit(1);
+}
+
+exit(0);
diff --git a/tests/afs_regression.php b/tests/afs_regression.php
new file mode 100644
index 00000000..4cef261f
--- /dev/null
+++ b/tests/afs_regression.php
@@ -0,0 +1,241 @@
+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;
+ }
+}
+
+$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)) {
+ @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();
+$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');
+
+$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[] = "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');
+
+$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');
+ }
+
+ 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");
+ }
+} finally {
+ @chdir($originalCwd);
+ remove_test_tree($tempRoot);
+}
+
+echo "1..$tests\n";
diff --git a/tests/afs_static.php b/tests/afs_static.php
new file mode 100644
index 00000000..2eb283d2
--- /dev/null
+++ b/tests/afs_static.php
@@ -0,0 +1,239 @@
+ $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);
+}
+
+echo "AFS static integration contract\n";
+
+// AFS must be an explicit config.php opt-in, and the include must be resolved
+// before the optional dependency is loaded.
+$defaultPos = strpos($manager, '$afsSupport = false;');
+$configPos = strpos($manager, '@include($config_file);');
+$guardPos = strpos($manager, 'if ($afsSupport) {');
+$requirePos = strpos($manager, "require_once __DIR__ . '/afs.php';");
+
+afs_test_ok($defaultPos !== false, 'AFS support defaults to disabled');
+afs_test_ok($configPos !== false, 'external config.php is included');
+afs_test_ok($guardPos !== false, 'AFS dependency load is conditional');
+afs_test_ok($requirePos !== false, 'AFS dependency uses an __DIR__-anchored path');
+afs_test_ok(
+ $defaultPos !== false && $configPos !== false && $guardPos !== false && $requirePos !== false
+ && $defaultPos < $configPos && $configPos < $guardPos && $guardPos < $requirePos,
+ 'config.php can override the default before afs.php is required'
+);
+
+// Preserve the upstream request-token checks around every route that already
+// had one. The legacy single-item GET copy route is intentionally audited in
+// afs_io_path_audit.php instead of being misrepresented as CSRF-protected.
+$ajax = afs_test_section($manager, '// Handle all AJAX Request', '// Delete file / folder', 'AJAX dispatcher');
+afs_test_contains($ajax, "isset(\$_POST['ajax'], \$_POST['token'])", 'AJAX dispatcher requires a token field');
+$ajaxVerify = strpos($ajax, "verifyToken(\$_POST['token'])");
+$ajaxSearch = strpos($ajax, '//search');
+afs_test_ok(
+ $ajaxVerify !== false && $ajaxSearch !== false && $ajaxVerify < $ajaxSearch,
+ 'AJAX token is verified before request-specific actions'
+);
+
+$csrfSections = array(
+ 'single delete' => 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'),
+ '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');
+}
+
+$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');
+
+// Preserve the current URL-upload boundary checks and the fork's proxy path.
+$urlUpload = afs_test_section($manager, '//upload using url', " exit();\n}", 'URL-upload route');
+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');
+
+// 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, '$afs->readAcl($aclPath)');
+$changeCall = strpos($aclSubmit, '$afs->changeAclEntries(');
+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\s*\([^;]*,\s*true\s*\)/s', $aclSubmit) === 1;
+$modeMap = $mappedAclSets;
+$variableNegativeCall = preg_match('/changeAclEntries\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, '$afs->readAcl($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, " |