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:
2025-11-04 00:56:49 +01:00
parent 30d15d1b20
commit 3085739e34
9 changed files with 1364 additions and 22 deletions

View File

@@ -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;

View File

@@ -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

View 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);
}
}

View File

@@ -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);

View 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,
];
}
}

View 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,
];
}
}