feat(filesystem): introduce FileOwnership and ProcessUser value objects
- Add `FileOwnership` to encapsulate file owner and group information. - Add `ProcessUser` to represent and manage system process user details. - Enhance ownership matching and debugging with structured data objects. - Include new documentation on file ownership handling and permission improvements. - Prepare infrastructure for enriched error handling in filesystem operations.
This commit is contained in:
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Discovery\Exceptions;
|
||||
|
||||
use App\Framework\Exception\Core\DiscoveryErrorCode;
|
||||
use App\Framework\Exception\Core\FileSystemErrorCode;
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
|
||||
@@ -68,7 +68,7 @@ final readonly class FileStreamProcessor
|
||||
} catch (\Throwable $e) {
|
||||
// Only log errors, not every processed file
|
||||
$this->logger?->warning(
|
||||
"Failed to process file {$file->getPath()->toString()}: {$e->getMessage()}"
|
||||
"Failed to process file {$file->getPath()->toString()}: {$e->getMessage()} in FileStreamProcessor"
|
||||
);
|
||||
} finally {
|
||||
// Always cleanup after processing a file
|
||||
|
||||
@@ -6,25 +6,43 @@ namespace App\Framework\Filesystem\Exceptions;
|
||||
|
||||
use App\Framework\Exception\Core\FilesystemErrorCode;
|
||||
use App\Framework\Exception\ExceptionContext;
|
||||
use App\Framework\Filesystem\PermissionChecker;
|
||||
use App\Framework\Filesystem\ValueObjects\FileOwnership;
|
||||
use App\Framework\Filesystem\ValueObjects\ProcessUser;
|
||||
|
||||
final class FilePermissionException extends FilesystemException
|
||||
{
|
||||
public function __construct(
|
||||
string $path,
|
||||
string $operation = 'access',
|
||||
?string $reason = null
|
||||
?string $reason = null,
|
||||
?array $diagnosticInfo = null,
|
||||
?ProcessUser $currentUser = null,
|
||||
?FileOwnership $fileOwnership = null,
|
||||
?FileOwnership $parentOwnership = null
|
||||
) {
|
||||
$message = "Permission denied for {$operation} on file: {$path}";
|
||||
|
||||
if ($reason) {
|
||||
$message .= " ({$reason})";
|
||||
}
|
||||
$message = $this->buildMessage($path, $operation, $reason, $diagnosticInfo, $currentUser, $fileOwnership, $parentOwnership);
|
||||
|
||||
$context = ExceptionContext::forOperation('file.permission', 'filesystem')
|
||||
->withData([
|
||||
'path' => $path,
|
||||
'operation' => $operation,
|
||||
'reason' => $reason,
|
||||
])
|
||||
->withDebug([
|
||||
'file_permissions' => $diagnosticInfo['permissions'] ?? null,
|
||||
'file_owner' => $diagnosticInfo['owner'] ?? null,
|
||||
'file_group' => $diagnosticInfo['group'] ?? null,
|
||||
'file_exists' => $diagnosticInfo['exists'] ?? null,
|
||||
'is_file' => $diagnosticInfo['is_file'] ?? null,
|
||||
'is_dir' => $diagnosticInfo['is_dir'] ?? null,
|
||||
'parent_dir' => $diagnosticInfo['parent_dir'] ?? null,
|
||||
'parent_writable' => $diagnosticInfo['parent_writable'] ?? null,
|
||||
])
|
||||
->withMetadata([
|
||||
'current_user' => $currentUser?->toArray(),
|
||||
'file_ownership' => $fileOwnership?->toArray(),
|
||||
'parent_ownership' => $parentOwnership?->toArray(),
|
||||
]);
|
||||
|
||||
parent::__construct(
|
||||
@@ -34,23 +52,147 @@ final class FilePermissionException extends FilesystemException
|
||||
);
|
||||
}
|
||||
|
||||
public static function read(string $path, ?string $reason = null): self
|
||||
/**
|
||||
* Build detailed error message with permission and user information
|
||||
*/
|
||||
private function buildMessage(
|
||||
string $path,
|
||||
string $operation,
|
||||
?string $reason,
|
||||
?array $diagnosticInfo,
|
||||
?ProcessUser $currentUser,
|
||||
?FileOwnership $fileOwnership,
|
||||
?FileOwnership $parentOwnership
|
||||
): string {
|
||||
$message = "Permission denied for {$operation} on file: {$path}";
|
||||
|
||||
if ($reason) {
|
||||
$message .= " ({$reason})";
|
||||
}
|
||||
|
||||
// Add file ownership information
|
||||
if ($fileOwnership !== null) {
|
||||
$message .= "\n File owner: {$fileOwnership->owner}, group: {$fileOwnership->group}";
|
||||
} elseif (isset($diagnosticInfo['owner'], $diagnosticInfo['group'])) {
|
||||
$message .= sprintf(
|
||||
"\n File owner: %s, group: %s",
|
||||
$diagnosticInfo['owner'],
|
||||
$diagnosticInfo['group']
|
||||
);
|
||||
}
|
||||
|
||||
// Add file permissions
|
||||
if (isset($diagnosticInfo['permissions'])) {
|
||||
$message .= ", permissions: {$diagnosticInfo['permissions']}";
|
||||
}
|
||||
|
||||
// Add parent directory information
|
||||
if (isset($diagnosticInfo['parent_dir'])) {
|
||||
$parentInfo = [];
|
||||
if ($parentOwnership !== null) {
|
||||
$parentInfo[] = "owner: {$parentOwnership->owner}";
|
||||
$parentInfo[] = "group: {$parentOwnership->group}";
|
||||
}
|
||||
if (isset($diagnosticInfo['parent_writable'])) {
|
||||
$parentInfo[] = 'writable: ' . ($diagnosticInfo['parent_writable'] ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
if (!empty($parentInfo)) {
|
||||
$message .= "\n Parent directory: {$diagnosticInfo['parent_dir']} (" . implode(', ', $parentInfo) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
// Add current process user information
|
||||
if ($currentUser !== null) {
|
||||
$message .= "\n Current process user: {$currentUser->toString()}";
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create exception with detailed permission information
|
||||
*/
|
||||
private static function createWithDetails(
|
||||
string $path,
|
||||
string $operation,
|
||||
?string $reason = null,
|
||||
?PermissionChecker $permissionChecker = null
|
||||
): self {
|
||||
$diagnosticInfo = null;
|
||||
$fileOwnership = null;
|
||||
$parentOwnership = null;
|
||||
|
||||
if ($permissionChecker !== null) {
|
||||
try {
|
||||
$diagnosticInfo = $permissionChecker->getDiagnosticInfo($path);
|
||||
// Use resolved_path from diagnostic info if available
|
||||
$resolvedPath = $diagnosticInfo['resolved_path'] ?? $path;
|
||||
|
||||
// Get file ownership
|
||||
if (file_exists($resolvedPath)) {
|
||||
$fileOwnership = FileOwnership::fromPath($resolvedPath);
|
||||
}
|
||||
|
||||
// Get parent directory ownership
|
||||
if (isset($diagnosticInfo['parent_dir'])) {
|
||||
$parentDir = $diagnosticInfo['parent_dir'];
|
||||
if (file_exists($parentDir)) {
|
||||
$parentOwnership = FileOwnership::fromPath($parentDir);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// If diagnostic info collection fails, continue without it
|
||||
$diagnosticInfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
$currentUser = ProcessUser::current();
|
||||
|
||||
return new self(
|
||||
path: $path,
|
||||
operation: $operation,
|
||||
reason: $reason,
|
||||
diagnosticInfo: $diagnosticInfo,
|
||||
currentUser: $currentUser,
|
||||
fileOwnership: $fileOwnership,
|
||||
parentOwnership: $parentOwnership
|
||||
);
|
||||
}
|
||||
|
||||
public static function read(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self
|
||||
{
|
||||
if ($permissionChecker !== null) {
|
||||
return self::createWithDetails($path, 'read', $reason, $permissionChecker);
|
||||
}
|
||||
|
||||
return new self($path, 'read', $reason);
|
||||
}
|
||||
|
||||
public static function write(string $path, ?string $reason = null): self
|
||||
public static function write(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self
|
||||
{
|
||||
if ($permissionChecker !== null) {
|
||||
return self::createWithDetails($path, 'write', $reason, $permissionChecker);
|
||||
}
|
||||
|
||||
return new self($path, 'write', $reason);
|
||||
}
|
||||
|
||||
public static function delete(string $path, ?string $reason = null): self
|
||||
public static function delete(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self
|
||||
{
|
||||
if ($permissionChecker !== null) {
|
||||
return self::createWithDetails($path, 'delete', $reason, $permissionChecker);
|
||||
}
|
||||
|
||||
return new self($path, 'delete', $reason);
|
||||
}
|
||||
|
||||
public static function createDirectory(string $path, ?string $reason = null): self
|
||||
public static function createDirectory(string $path, ?string $reason = null, ?PermissionChecker $permissionChecker = null): self
|
||||
{
|
||||
if ($permissionChecker !== null) {
|
||||
return self::createWithDetails($path, 'create directory', $reason, $permissionChecker);
|
||||
}
|
||||
|
||||
return new self($path, 'create directory', $reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,14 +115,14 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt
|
||||
}
|
||||
|
||||
if (! is_readable($resolvedPath)) {
|
||||
throw FilePermissionException::read($path, 'File is not readable');
|
||||
throw FilePermissionException::read($path, 'File is not readable', $this->permissions);
|
||||
}
|
||||
|
||||
$content = @file_get_contents($resolvedPath);
|
||||
if ($content === false) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::read($path, $error['message']);
|
||||
throw FilePermissionException::read($path, $error['message'], $this->permissions);
|
||||
}
|
||||
|
||||
throw new FileReadException($path);
|
||||
@@ -156,24 +156,24 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt
|
||||
if (! @mkdir($dir, 0777, true) && ! is_dir($dir)) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::createDirectory($dir, $error['message']);
|
||||
throw FilePermissionException::createDirectory($dir, $error['message'], $this->permissions);
|
||||
}
|
||||
|
||||
throw new DirectoryCreateException($dir);
|
||||
}
|
||||
} elseif (! is_writable($dir)) {
|
||||
throw FilePermissionException::write($path, 'Directory is not writable: ' . $dir);
|
||||
throw FilePermissionException::write($path, 'Directory is not writable: ' . $dir, $this->permissions);
|
||||
}
|
||||
|
||||
// Prüfe File-Permissions wenn Datei bereits existiert
|
||||
if (is_file($resolvedPath) && ! is_writable($resolvedPath)) {
|
||||
throw FilePermissionException::write($path, 'File is not writable');
|
||||
throw FilePermissionException::write($path, 'File is not writable', $this->permissions);
|
||||
}
|
||||
|
||||
if (@file_put_contents($resolvedPath, $content) === false) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::write($path, $error['message']);
|
||||
throw FilePermissionException::write($path, $error['message'], $this->permissions);
|
||||
}
|
||||
|
||||
throw new FileWriteException($path);
|
||||
@@ -229,7 +229,7 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt
|
||||
if (! @unlink($resolvedPath)) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::delete($path, $error['message']);
|
||||
throw FilePermissionException::delete($path, $error['message'], $this->permissions);
|
||||
}
|
||||
|
||||
throw new FileDeleteException($path);
|
||||
@@ -386,13 +386,13 @@ final readonly class FileStorage implements Storage, AtomicStorage, AppendableSt
|
||||
// Prüfe Parent-Directory Permissions
|
||||
$parentDir = dirname($resolvedPath);
|
||||
if (is_dir($parentDir) && ! is_writable($parentDir)) {
|
||||
throw FilePermissionException::createDirectory($path, 'Parent directory is not writable: ' . $parentDir);
|
||||
throw FilePermissionException::createDirectory($path, 'Parent directory is not writable: ' . $parentDir, $this->permissions);
|
||||
}
|
||||
|
||||
if (! @mkdir($resolvedPath, $permissions, $recursive) && ! is_dir($resolvedPath)) {
|
||||
$error = error_get_last();
|
||||
if ($error && str_contains($error['message'], 'Permission denied')) {
|
||||
throw FilePermissionException::createDirectory($path, $error['message']);
|
||||
throw FilePermissionException::createDirectory($path, $error['message'], $this->permissions);
|
||||
}
|
||||
|
||||
throw new DirectoryCreateException($path);
|
||||
|
||||
116
src/Framework/Filesystem/ValueObjects/FileOwnership.php
Normal file
116
src/Framework/Filesystem/ValueObjects/FileOwnership.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object representing file ownership (owner and group)
|
||||
*/
|
||||
final readonly class FileOwnership
|
||||
{
|
||||
public function __construct(
|
||||
public string $owner,
|
||||
public string $group,
|
||||
public int $ownerUid,
|
||||
public int $groupGid
|
||||
) {
|
||||
if (empty($owner)) {
|
||||
throw new \InvalidArgumentException("Owner name cannot be empty");
|
||||
}
|
||||
|
||||
if (empty($group)) {
|
||||
throw new \InvalidArgumentException("Group name cannot be empty");
|
||||
}
|
||||
|
||||
if ($ownerUid < 0) {
|
||||
throw new \InvalidArgumentException("Invalid owner UID: {$ownerUid}");
|
||||
}
|
||||
|
||||
if ($groupGid < 0) {
|
||||
throw new \InvalidArgumentException("Invalid group GID: {$groupGid}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file ownership from a file path
|
||||
*
|
||||
* @return self|null Returns null if file doesn't exist or posix functions are not available
|
||||
*/
|
||||
public static function fromPath(string $path): ?self
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!function_exists('fileowner') || !function_exists('filegroup')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$ownerUid = fileowner($path);
|
||||
$groupGid = filegroup($path);
|
||||
|
||||
if ($ownerUid === false || $groupGid === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userInfo = posix_getpwuid($ownerUid);
|
||||
$groupInfo = posix_getgrgid($groupGid);
|
||||
|
||||
if ($userInfo === false || $groupInfo === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
owner: $userInfo['name'] ?? 'unknown',
|
||||
group: $groupInfo['name'] ?? 'unknown',
|
||||
ownerUid: $ownerUid,
|
||||
groupGid: $groupGid
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ownership matches a process user
|
||||
*/
|
||||
public function matchesProcessUser(?ProcessUser $processUser): bool
|
||||
{
|
||||
if ($processUser === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->ownerUid === $processUser->uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ownership matches by user name
|
||||
*/
|
||||
public function matchesOwner(string $userName): bool
|
||||
{
|
||||
return $this->owner === $userName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return "{$this->owner}:{$this->group} (uid: {$this->ownerUid}, gid: {$this->groupGid})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/debugging
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'owner' => $this->owner,
|
||||
'group' => $this->group,
|
||||
'owner_uid' => $this->ownerUid,
|
||||
'group_gid' => $this->groupGid,
|
||||
];
|
||||
}
|
||||
}
|
||||
108
src/Framework/Filesystem/ValueObjects/ProcessUser.php
Normal file
108
src/Framework/Filesystem/ValueObjects/ProcessUser.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Filesystem\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object representing the current process user
|
||||
*/
|
||||
final readonly class ProcessUser
|
||||
{
|
||||
public function __construct(
|
||||
public int $uid,
|
||||
public int $gid,
|
||||
public string $name,
|
||||
public string $group
|
||||
) {
|
||||
if ($uid < 0) {
|
||||
throw new \InvalidArgumentException("Invalid user ID: {$uid}");
|
||||
}
|
||||
|
||||
if ($gid < 0) {
|
||||
throw new \InvalidArgumentException("Invalid group ID: {$gid}");
|
||||
}
|
||||
|
||||
if (empty($name)) {
|
||||
throw new \InvalidArgumentException("User name cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current process user from system
|
||||
*
|
||||
* @return self|null Returns null if posix functions are not available
|
||||
*/
|
||||
public static function current(): ?self
|
||||
{
|
||||
if (!function_exists('posix_geteuid')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$uid = posix_geteuid();
|
||||
$gid = posix_getegid();
|
||||
$userInfo = posix_getpwuid($uid);
|
||||
$groupInfo = posix_getgrgid($gid);
|
||||
|
||||
if ($userInfo === false || $groupInfo === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
uid: $uid,
|
||||
gid: $gid,
|
||||
name: $userInfo['name'] ?? 'unknown',
|
||||
group: $groupInfo['name'] ?? 'unknown'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// posix functions failed - return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user is root (uid 0)
|
||||
*/
|
||||
public function isRoot(): bool
|
||||
{
|
||||
return $this->uid === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user matches another user by name
|
||||
*/
|
||||
public function matchesName(string $userName): bool
|
||||
{
|
||||
return $this->name === $userName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this user matches another user by UID
|
||||
*/
|
||||
public function matchesUid(int $uid): bool
|
||||
{
|
||||
return $this->uid === $uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string representation
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return "{$this->name} (uid: {$this->uid}, gid: {$this->gid}, group: {$this->group})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/debugging
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uid' => $this->uid,
|
||||
'gid' => $this->gid,
|
||||
'name' => $this->name,
|
||||
'group' => $this->group,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user