Skip to content

Commit

Permalink
fix persisting files on a case insensitive filesystem
Browse files Browse the repository at this point in the history
  • Loading branch information
Baptouuuu committed Jan 2, 2023
1 parent 8bed8cd commit af43100
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 6 deletions.
4 changes: 4 additions & 0 deletions .journal
Expand Up @@ -46,6 +46,10 @@ return static function(Config $config): Config
'Load FTP files',
Path::of('use_cases/load_ftp_files.md'),
),
Entry::markdown(
'Case sensitivity',
Path::of('case_sensitivity.md'),
),
),
Entry::section(
'Testing',
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog

## [Unreleased]

### Added

- `Innmind\Filesystem\CaseSensitivity` enum
- `Innmind\Filesystem\Adapter\Filesystem::withCaseSensitivity()`

## 6.0.0 - 2022-12-18

### Added
Expand Down
19 changes: 19 additions & 0 deletions documentation/case_sensitivity.md
@@ -0,0 +1,19 @@
# Working with case insensitive filesystems

By default this package assumes you're working with a case sensitive filesystem, meaning you can have 2 files `a` and `A` inside a same directory. However this is not possible on a case insensitive filesystem (such as APFS on macOS), it will create only one of both.

If you're dealing with a case insensitive filesystem then you need to specify it on the adapter like this:

```php
use Innmind\Filesystem\{
Adapter\Filesystem,
CaseSensitivity,
};
use Innmind\Url\Path;

$adapter = Filesystem::mount(Path::of('somewhere/'))
->withCaseSensitivity(CaseSensitivity::insensitive);
$adapter instanceof Filesystem; // true, use $adapter as usual
```

**Advice**: If you persist user provided files on a filesystem you should use normalized names (like UUIDs) and keep the original names in a database to avoid collisions.
1 change: 1 addition & 0 deletions documentation/readme.md
Expand Up @@ -17,4 +17,5 @@ composer require innmind/filesystem
- [Delete a file](use_cases/delete_file.md)
- [Backup a directory](use_cases/backup_directory.md)
- [Load FTP files](use_cases/load_ftp_files.md)
- [Working with case insensitive filesystems](case_sensitivity.md)

19 changes: 14 additions & 5 deletions src/Adapter/Filesystem.php
Expand Up @@ -9,6 +9,7 @@
Name,
Directory,
Chunk,
CaseSensitivity,
Exception\PathDoesntRepresentADirectory,
Exception\PathTooLong,
Exception\RuntimeException,
Expand Down Expand Up @@ -38,18 +39,20 @@ final class Filesystem implements Adapter
{
private const INVALID_FILES = ['.', '..'];
private Path $path;
private CaseSensitivity $case;
private FS $filesystem;
private Chunk $chunk;
/** @var \WeakMap<File, Path> */
private \WeakMap $loaded;

private function __construct(Path $path)
private function __construct(Path $path, CaseSensitivity $case)
{
if (!$path->directory()) {
throw new PathDoesntRepresentADirectory($path->toString());
}

$this->path = $path;
$this->case = $case;
$this->filesystem = new FS;
$this->chunk = new Chunk;
/** @var \WeakMap<File, Path> */
Expand All @@ -62,7 +65,12 @@ private function __construct(Path $path)

public static function mount(Path $path): self
{
return new self($path);
return new self($path, CaseSensitivity::sensitive);
}

public function withCaseSensitivity(CaseSensitivity $case): self
{
return new self($this->path, $case);
}

public function add(File $file): void
Expand Down Expand Up @@ -125,20 +133,21 @@ private function createFileAt(Path $path, File $file): void

if ($file instanceof Directory) {
$this->filesystem->mkdir($path->toString());
/** @var Set<Name> */
$persisted = $file->reduce(
Set::strings(),
Set::of(),
function(Set $persisted, File $file) use ($path): Set {
$this->createFileAt($path, $file);

return ($persisted)($file->name()->toString());
return ($persisted)($file->name());
},
);
/**
* @psalm-suppress MissingClosureReturnType
*/
$_ = $file
->removed()
->filter(static fn($file): bool => !$persisted->contains($file->toString()))
->filter(fn($file): bool => !$this->case->contains($file, $persisted))
->foreach(fn($file) => $this->filesystem->remove(
$path->toString().$file->toString(),
));
Expand Down
37 changes: 37 additions & 0 deletions src/CaseSensitivity.php
@@ -0,0 +1,37 @@
<?php
declare(strict_types = 1);

namespace Innmind\Filesystem;

use Innmind\Immutable\Set;

enum CaseSensitivity
{
case sensitive;
case insensitive;

/**
* @internal
*
* @param Set<Name> $in
*/
public function contains(Name $name, Set $in): bool
{
return match ($this) {
self::sensitive => $in
->map($this->normalize(...))
->contains($this->normalize($name)),
self::insensitive => $in
->map($this->normalize(...))
->contains($this->normalize($name)),
};
}

private function normalize(Name $name): string
{
return match ($this) {
self::sensitive => $name->toString(),
self::insensitive => $name->str()->toLower()->toString(),
};
}
}
7 changes: 6 additions & 1 deletion tests/Adapter/FilesystemTest.php
Expand Up @@ -6,6 +6,7 @@
use Innmind\Filesystem\{
Adapter\Filesystem,
Adapter,
CaseSensitivity,
File\File,
File\Content\None,
File\Content\Lines,
Expand Down Expand Up @@ -500,8 +501,12 @@ public function testRegressionAddingFileInDirectoryDueToCaseSensitivity()

$path = \sys_get_temp_dir().'/innmind/filesystem/';
(new FS)->remove($path);
$adapter = Filesystem::mount(Path::of($path))->withCaseSensitivity(match (\PHP_OS) {
'Darwin' => CaseSensitivity::insensitive,
default => CaseSensitivity::sensitive,
});

$property->ensureHeldBy(Filesystem::mount(Path::of($path)));
$property->ensureHeldBy($adapter);

(new FS)->remove($path);
}
Expand Down
65 changes: 65 additions & 0 deletions tests/CaseSensitivityTest.php
@@ -0,0 +1,65 @@
<?php
declare(strict_types = 1);

namespace Tests\Innmind\Filesystem;

use Innmind\Filesystem\{
CaseSensitivity,
Name,
};
use Innmind\Immutable\Set as ISet;
use PHPUnit\Framework\TestCase;
use Innmind\BlackBox\{
PHPUnit\BlackBox,
Set,
};
use Fixtures\Innmind\Filesystem\Name as FName;

class CaseSensitivityTest extends TestCase
{
use BlackBox;

public function testContains()
{
$this
->forAll(
FName::strings(),
FName::strings(),
)
->filter(static fn($a, $b) => $a !== $b)
->then(function($a, $b) {
$this->assertTrue(CaseSensitivity::sensitive->contains(
Name::of($a),
ISet::of(Name::of($a)),
));
$this->assertFalse(CaseSensitivity::sensitive->contains(
Name::of($a),
ISet::of(Name::of($b)),
));
});
$this
->forAll(
FName::strings(),
Set\Sequence::of(FName::strings()),
)
->filter(static fn($a, $b) => !\in_array($a, $b, true))
->then(function($a, $b) {
$this->assertFalse(CaseSensitivity::sensitive->contains(
Name::of($a),
ISet::of(...$b)->map(Name::of(...)),
));
});
$this
->forAll(FName::strings())
->then(function($a) {
$this->assertTrue(CaseSensitivity::insensitive->contains(
Name::of($a),
ISet::of($a)->map(\strtolower(...))->map(Name::of(...)),
));
$this->assertTrue(CaseSensitivity::insensitive->contains(
Name::of($a),
ISet::of($a)->map(\strtoupper(...))->map(Name::of(...)),
));
});
}
}

0 comments on commit af43100

Please sign in to comment.