├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── lib ├── depot.ex └── depot │ ├── adapter.ex │ ├── adapter │ ├── in_memory.ex │ └── local.ex │ ├── adapter_test.ex │ ├── application.ex │ ├── filesystem.ex │ ├── registry.ex │ ├── relative_path.ex │ ├── stat │ ├── dir.ex │ └── file.ex │ └── visibility │ ├── portable_unix_visibility_converter.ex │ ├── unix_visibility_converter.ex │ └── visibility.ex ├── mix.exs ├── mix.lock └── test ├── depot ├── adapter │ ├── in_memory_test.exs │ └── local_test.exs └── relative_path_test.exs ├── depot_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [assert_in_list: 2] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - pair: 19 | elixir: '1.11.4' 20 | otp: '22.2' 21 | - pair: 22 | elixir: '1.12.3' 23 | otp: '24.0' 24 | lint: lint 25 | 26 | # services: 27 | # postgres: 28 | # image: postgres:12 29 | # ports: 30 | # - 5432:5432 31 | # env: 32 | # POSTGRES_USER: postgres 33 | # POSTGRES_PASSWORD: postgres 34 | # # Set health checks to wait until postgres has started 35 | # options: >- 36 | # --health-cmd pg_isready 37 | # --health-interval 10s 38 | # --health-timeout 5s 39 | # --health-retries 5 40 | 41 | steps: 42 | - uses: actions/checkout@v2 43 | 44 | - uses: actions/cache@v2 45 | with: 46 | path: | 47 | deps 48 | _build 49 | key: ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-${{ hashFiles('**/mix.lock') }} 50 | restore-keys: | 51 | ${{ runner.os }}-mix- 52 | 53 | - uses: erlef/setup-beam@v1 54 | with: 55 | otp-version: ${{matrix.pair.otp}} 56 | elixir-version: ${{matrix.pair.elixir}} 57 | 58 | - run: mix deps.get 59 | 60 | - run: mix format --check-formatted 61 | if: ${{ matrix.lint }} 62 | 63 | - run: mix deps.unlock --check-unused 64 | if: ${{ matrix.lint }} 65 | 66 | - run: mix deps.compile 67 | 68 | - run: mix compile --warnings-as-errors 69 | if: ${{ matrix.lint }} 70 | 71 | - run: mix test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Temporary test files 14 | /tmp/ 15 | 16 | # Ignore .fetch files in case you like to edit your project deps locally. 17 | /.fetch 18 | 19 | # If the VM crashes, it generates a dump, let's ignore it too. 20 | erl_crash.dump 21 | 22 | # Also ignore archive artifacts (built via "mix archive.build"). 23 | *.ez 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | depot-*.tar 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ### Changed 9 | - Updated Elixir to ~> 1.11 10 | - Replaced Briefly test dependency with ExUnit's :tmp_dir 11 | 12 | ## [0.5.2] - 2021-09-16 13 | ### Added 14 | - Added WIP visibility handling with callbacks and converters 15 | - Runtime configuration for filesystems via the app env 16 | 17 | ## [0.5.1] - 2021-09-12 18 | ### Changed 19 | - `Depot.RelativePath.join_prefix` does make sure trailing slashes are retained 20 | 21 | 22 | ## [0.5.0] - 2020-08-16 23 | ### Added 24 | - New `Depot.Filesystem` callback `copy/4` to implement copy between filesystems 25 | - New `Depot.Filesystem` callback `file_exists/2` 26 | - New `Depot.Filesystem` callback `list_contents/2` 27 | - New `Depot.Filesystem` callback `create_directory/2` 28 | - New `Depot.Filesystem` callback `delete_directory/2` 29 | - New `Depot.Filesystem` callback `clear/1` 30 | 31 | 32 | ## [0.4.0] - 2020-07-31 33 | ### Added 34 | - New `Depot.Filesystem` callback `copy/4` to implement copy between filesystems 35 | 36 | 37 | ## [0.3.0] - 2020-07-29 38 | ### Added 39 | - New `Depot.Filesystem` callback `read_stream/2` 40 | - Added `:otp_app` key to `use Depot.Filesystem` macro to be able to store settings 41 | in config files -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Benjamin Milde 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Depot 2 | 3 | ![Elixir CI](https://github.com/LostKobrakai/depot/workflows/Elixir%20CI/badge.svg) 4 | [Hex Package](https://hex.pm/depot) | 5 | [Online Documentation](https://hexdocs.pm/depot). 6 | 7 | 8 | 9 | Depot is a filesystem abstraction for elixir providing a unified interface over many implementations. It allows you to swap out filesystems on the fly without needing to rewrite all of your application code in the process. It can eliminate vendor-lock in, reduce technical debt, and improve the testability of your code. 10 | 11 | This library is based on the ideas of [flysystem](http://flysystem.thephpleague.com/), which is a PHP library providing similar functionality. 12 | 13 | ## Examples 14 | 15 | ```elixir 16 | defmodule LocalFileSystem do 17 | use Depot.Filesystem, 18 | adapter: Depot.Adapter.Local, 19 | prefix: prefix 20 | end 21 | 22 | LocalFileSystem.write("test.txt", "Hello World") 23 | {:ok, "Hello World"} = LocalFileSystem.read("test.txt") 24 | ``` 25 | 26 | ## Visibility 27 | 28 | Depot does by default only deal with a limited, but portable, set of visibility permissions: 29 | 30 | - `:public` 31 | - `:private` 32 | 33 | For more details and how to apply custom visibility permissions take a look at `Depot.Visibility` 34 | 35 | ## Options 36 | 37 | The following write options apply to all adapters: 38 | 39 | * `:visibility` - Set the visibility for files written 40 | * `:directory_visibility` - Set the visibility for directories written (if applicable) 41 | 42 | 43 | 44 | ## Installation 45 | 46 | The package can be installed by adding `depot` to your list of dependencies in `mix.exs`: 47 | 48 | ```elixir 49 | def deps do 50 | [ 51 | {:depot, "~> 0.1.0"} 52 | ] 53 | end 54 | ``` 55 | -------------------------------------------------------------------------------- /lib/depot.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot do 2 | @external_resource "README.md" 3 | @moduledoc @external_resource 4 | |> File.read!() 5 | |> String.split("") 6 | |> Enum.fetch!(1) 7 | 8 | @type adapter :: module() 9 | @type filesystem :: {module(), Depot.Adapter.config()} 10 | 11 | @doc """ 12 | Write to a filesystem 13 | 14 | ## Examples 15 | 16 | ### Direct filesystem 17 | 18 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 19 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 20 | 21 | ### Module-based filesystem 22 | 23 | defmodule LocalFileSystem do 24 | use Depot.Filesystem, 25 | adapter: Depot.Adapter.Local, 26 | prefix: "/home/user/storage" 27 | end 28 | 29 | LocalFileSystem.write("test.txt", "Hello World") 30 | 31 | """ 32 | @spec write(filesystem, Path.t(), iodata(), keyword()) :: :ok | {:error, term} 33 | def write({adapter, config}, path, contents, opts \\ []) do 34 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 35 | adapter.write(config, path, contents, opts) 36 | end 37 | end 38 | 39 | @doc """ 40 | Returns a `Stream` for writing to the given `path`. 41 | 42 | ## Options 43 | 44 | The following stream options apply to all adapters: 45 | 46 | * `:chunk_size` - When reading, the amount to read, 47 | usually expressed as a number of bytes. 48 | 49 | ## Examples 50 | 51 | > Note: The shape of the returned stream will 52 | > necessarily depend on the adapter in use. In the 53 | > following examples the [`Local`](`Depot.Adapter.Local`) 54 | > adapter is invoked, which returns a `File.Stream`. 55 | 56 | ### Direct filesystem 57 | 58 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 59 | {:ok, %File.Stream{}} = Depot.write_stream(filesystem, "test.txt") 60 | 61 | ### Module-based filesystem 62 | 63 | defmodule LocalFileSystem do 64 | use Depot.Filesystem, 65 | adapter: Depot.Adapter.Local, 66 | prefix: "/home/user/storage" 67 | end 68 | 69 | {:ok, %File.Stream{}} = LocalFileSystem.write_stream("test.txt") 70 | 71 | """ 72 | def write_stream({adapter, config}, path, opts \\ []) do 73 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 74 | adapter.write_stream(config, path, opts) 75 | end 76 | end 77 | 78 | @doc """ 79 | Read from a filesystem 80 | 81 | ## Examples 82 | 83 | ### Direct filesystem 84 | 85 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 86 | {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 87 | 88 | ### Module-based filesystem 89 | 90 | defmodule LocalFileSystem do 91 | use Depot.Filesystem, 92 | adapter: Depot.Adapter.Local, 93 | prefix: "/home/user/storage" 94 | end 95 | 96 | {:ok, "Hello World"} = LocalFileSystem.read("test.txt") 97 | 98 | """ 99 | @spec read(filesystem, Path.t(), keyword()) :: {:ok, binary} | {:error, term} 100 | def read({adapter, config}, path, _opts \\ []) do 101 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 102 | adapter.read(config, path) 103 | end 104 | end 105 | 106 | @doc """ 107 | Returns a `Stream` for reading the given `path`. 108 | 109 | ## Options 110 | 111 | The following stream options apply to all adapters: 112 | 113 | * `:chunk_size` - When reading, the amount to read, 114 | usually expressed as a number of bytes. 115 | 116 | ## Examples 117 | 118 | > Note: The shape of the returned stream will 119 | > necessarily depend on the adapter in use. In the 120 | > following examples the [`Local`](`Depot.Adapter.Local`) 121 | > adapter is invoked, which returns a `File.Stream`. 122 | 123 | ### Direct filesystem 124 | 125 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 126 | {:ok, %File.Stream{}} = Depot.read_stream(filesystem, "test.txt") 127 | 128 | ### Module-based filesystem 129 | 130 | defmodule LocalFileSystem do 131 | use Depot.Filesystem, 132 | adapter: Depot.Adapter.Local, 133 | prefix: "/home/user/storage" 134 | end 135 | 136 | {:ok, %File.Stream{}} = LocalFileSystem.read_stream("test.txt") 137 | 138 | """ 139 | def read_stream({adapter, config}, path, opts \\ []) do 140 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 141 | adapter.read_stream(config, path, opts) 142 | end 143 | end 144 | 145 | @doc """ 146 | Delete a file from a filesystem 147 | 148 | ## Examples 149 | 150 | ### Direct filesystem 151 | 152 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 153 | :ok = Depot.delete(filesystem, "test.txt") 154 | 155 | ### Module-based filesystem 156 | 157 | defmodule LocalFileSystem do 158 | use Depot.Filesystem, 159 | adapter: Depot.Adapter.Local, 160 | prefix: "/home/user/storage" 161 | end 162 | 163 | :ok = LocalFileSystem.delete("test.txt") 164 | 165 | """ 166 | @spec delete(filesystem, Path.t(), keyword()) :: :ok | {:error, term} 167 | def delete({adapter, config}, path, _opts \\ []) do 168 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 169 | adapter.delete(config, path) 170 | end 171 | end 172 | 173 | @doc """ 174 | Move a file from source to destination on a filesystem 175 | 176 | ## Examples 177 | 178 | ### Direct filesystem 179 | 180 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 181 | :ok = Depot.move(filesystem, "test.txt", "other-test.txt") 182 | 183 | ### Module-based filesystem 184 | 185 | defmodule LocalFileSystem do 186 | use Depot.Filesystem, 187 | adapter: Depot.Adapter.Local, 188 | prefix: "/home/user/storage" 189 | end 190 | 191 | :ok = LocalFileSystem.move("test.txt", "other-test.txt") 192 | 193 | """ 194 | @spec move(filesystem, Path.t(), Path.t(), keyword()) :: :ok | {:error, term} 195 | def move({adapter, config}, source, destination, opts \\ []) do 196 | with {:ok, source} <- Depot.RelativePath.normalize(source), 197 | {:ok, destination} <- Depot.RelativePath.normalize(destination) do 198 | adapter.move(config, source, destination, opts) 199 | end 200 | end 201 | 202 | @doc """ 203 | Copy a file from source to destination on a filesystem 204 | 205 | ## Examples 206 | 207 | ### Direct filesystem 208 | 209 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 210 | :ok = Depot.copy(filesystem, "test.txt", "other-test.txt") 211 | 212 | ### Module-based filesystem 213 | 214 | defmodule LocalFileSystem do 215 | use Depot.Filesystem, 216 | adapter: Depot.Adapter.Local, 217 | prefix: "/home/user/storage" 218 | end 219 | 220 | :ok = LocalFileSystem.copy("test.txt", "other-test.txt") 221 | 222 | """ 223 | @spec copy(filesystem, Path.t(), Path.t(), keyword()) :: :ok | {:error, term} 224 | def copy({adapter, config}, source, destination, opts \\ []) do 225 | with {:ok, source} <- Depot.RelativePath.normalize(source), 226 | {:ok, destination} <- Depot.RelativePath.normalize(destination) do 227 | adapter.copy(config, source, destination, opts) 228 | end 229 | end 230 | 231 | @doc """ 232 | Copy a file from source to destination on a filesystem 233 | 234 | ## Examples 235 | 236 | ### Direct filesystem 237 | 238 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 239 | :ok = Depot.copy(filesystem, "test.txt", "other-test.txt") 240 | 241 | ### Module-based filesystem 242 | 243 | defmodule LocalFileSystem do 244 | use Depot.Filesystem, 245 | adapter: Depot.Adapter.Local, 246 | prefix: "/home/user/storage" 247 | end 248 | 249 | :ok = LocalFileSystem.copy("test.txt", "other-test.txt") 250 | 251 | """ 252 | @spec file_exists(filesystem, Path.t(), keyword()) :: {:ok, :exists | :missing} | {:error, term} 253 | def file_exists({adapter, config}, path, _opts \\ []) do 254 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 255 | adapter.file_exists(config, path) 256 | end 257 | end 258 | 259 | @doc """ 260 | List the contents of a folder on a filesystem 261 | 262 | ## Examples 263 | 264 | ### Direct filesystem 265 | 266 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 267 | {:ok, contents} = Depot.list_contents(filesystem, ".") 268 | 269 | ### Module-based filesystem 270 | 271 | defmodule LocalFileSystem do 272 | use Depot.Filesystem, 273 | adapter: Depot.Adapter.Local, 274 | prefix: "/home/user/storage" 275 | end 276 | 277 | {:ok, contents} = LocalFileSystem.list_contents(".") 278 | 279 | """ 280 | @spec list_contents(filesystem, Path.t(), keyword()) :: 281 | {:ok, [%Depot.Stat.Dir{} | %Depot.Stat.File{}]} | {:error, term} 282 | def list_contents({adapter, config}, path, _opts \\ []) do 283 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 284 | adapter.list_contents(config, path) 285 | end 286 | end 287 | 288 | @doc """ 289 | Create a directory 290 | 291 | ## Examples 292 | 293 | ### Direct filesystem 294 | 295 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 296 | :ok = Depot.create_directory(filesystem, "test/") 297 | 298 | ### Module-based filesystem 299 | 300 | defmodule LocalFileSystem do 301 | use Depot.Filesystem, 302 | adapter: Depot.Adapter.Local, 303 | prefix: "/home/user/storage" 304 | end 305 | 306 | LocalFileSystem.create_directory("test/") 307 | 308 | """ 309 | @spec create_directory(filesystem, Path.t(), keyword()) :: :ok | {:error, term} 310 | def create_directory({adapter, config}, path, opts \\ []) do 311 | with {:ok, path} <- Depot.RelativePath.normalize(path), 312 | {:ok, path} <- Depot.RelativePath.assert_directory(path) do 313 | adapter.create_directory(config, path, opts) 314 | end 315 | end 316 | 317 | @doc """ 318 | Delete a directory. 319 | 320 | ## Options 321 | 322 | * `:recursive` - Recursively delete contents. Defaults to `false`. 323 | 324 | ## Examples 325 | 326 | ### Direct filesystem 327 | 328 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 329 | :ok = Depot.delete_directory(filesystem, "test/") 330 | 331 | ### Module-based filesystem 332 | 333 | defmodule LocalFileSystem do 334 | use Depot.Filesystem, 335 | adapter: Depot.Adapter.Local, 336 | prefix: "/home/user/storage" 337 | end 338 | 339 | LocalFileSystem.delete_directory("test/") 340 | 341 | """ 342 | @spec delete_directory(filesystem, Path.t(), keyword()) :: :ok | {:error, term} 343 | def delete_directory({adapter, config}, path, opts \\ []) do 344 | with {:ok, path} <- Depot.RelativePath.normalize(path), 345 | {:ok, path} <- Depot.RelativePath.assert_directory(path) do 346 | adapter.delete_directory(config, path, opts) 347 | end 348 | end 349 | 350 | @doc """ 351 | Clear the filesystem. 352 | 353 | This is always recursive. 354 | 355 | ## Examples 356 | 357 | ### Direct filesystem 358 | 359 | filesystem = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 360 | :ok = Depot.clear(filesystem) 361 | 362 | ### Module-based filesystem 363 | 364 | defmodule LocalFileSystem do 365 | use Depot.Filesystem, 366 | adapter: Depot.Adapter.Local, 367 | prefix: "/home/user/storage" 368 | end 369 | 370 | LocalFileSystem.clear() 371 | 372 | """ 373 | @spec clear(filesystem, keyword()) :: :ok | {:error, term} 374 | def clear({adapter, config}, _opts \\ []) do 375 | adapter.clear(config) 376 | end 377 | 378 | @spec set_visibility(filesystem, Path.t(), Depot.Visibility.t()) :: :ok | {:error, term} 379 | def set_visibility({adapter, config}, path, visibility) do 380 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 381 | adapter.set_visibility(config, path, visibility) 382 | end 383 | end 384 | 385 | @spec visibility(filesystem, Path.t()) :: {:ok, Depot.Visibility.t()} | {:error, term} 386 | def visibility({adapter, config}, path) do 387 | with {:ok, path} <- Depot.RelativePath.normalize(path) do 388 | adapter.visibility(config, path) 389 | end 390 | end 391 | 392 | @doc """ 393 | Copy a file from one filesystem to the other 394 | 395 | This can either be done natively if the same adapter is used for both filesystems 396 | or by streaming/read-write cycle the file from the source to the local system 397 | and back to the destination. 398 | 399 | ## Examples 400 | 401 | ### Direct filesystem 402 | 403 | filesystem_source = Depot.Adapter.Local.configure(prefix: "/home/user/storage") 404 | filesystem_destination = Depot.Adapter.Local.configure(prefix: "/home/user/storage2") 405 | :ok = Depot.copy_between_filesystem({filesystem_source, "test.txt"}, {filesystem_destination, "copy.txt"}) 406 | 407 | ### Module-based filesystem 408 | 409 | defmodule LocalSourceFileSystem do 410 | use Depot.Filesystem, 411 | adapter: Depot.Adapter.Local, 412 | prefix: "/home/user/storage" 413 | end 414 | 415 | defmodule LocalDestinationFileSystem do 416 | use Depot.Filesystem, 417 | adapter: Depot.Adapter.Local, 418 | prefix: "/home/user/storage2" 419 | end 420 | 421 | :ok = Depot.copy_between_filesystem( 422 | {LocalSourceFileSystem.__filesystem__(), "test.txt"}, 423 | {LocalDestinationFileSystem.__filesystem__(), "copy.txt"} 424 | ) 425 | 426 | """ 427 | @spec copy_between_filesystem( 428 | source :: {filesystem, Path.t()}, 429 | destination :: {filesystem, Path.t()}, 430 | keyword() 431 | ) :: :ok | {:error, term} 432 | def copy_between_filesystem(source, destination, opts \\ []) 433 | 434 | # Same adapter, same config -> just do a plain copy 435 | def copy_between_filesystem({filesystem, source}, {filesystem, destination}, opts) do 436 | copy(filesystem, source, destination, opts) 437 | end 438 | 439 | # Same adapter -> try direct copy if supported 440 | def copy_between_filesystem( 441 | {{adapter, config_source}, path_source} = source, 442 | {{adapter, config_destination}, path_destination} = destination, 443 | opts 444 | ) do 445 | with :ok <- 446 | adapter.copy(config_source, path_source, config_destination, path_destination, opts) do 447 | :ok 448 | else 449 | {:error, :unsupported} -> copy_via_local_memory(source, destination, opts) 450 | error -> error 451 | end 452 | end 453 | 454 | # different adapter 455 | def copy_between_filesystem(source, destination, opts) do 456 | copy_via_local_memory(source, destination, opts) 457 | end 458 | 459 | defp copy_via_local_memory( 460 | {{source_adapter, _} = source_filesystem, source_path}, 461 | {{destination_adapter, _} = destination_filesystem, destination_path}, 462 | opts 463 | ) do 464 | case {Depot.read_stream(source_filesystem, source_path, opts), 465 | Depot.write_stream(destination_filesystem, destination_path, opts)} do 466 | # A and B support streaming -> Stream data 467 | {{:ok, read_stream}, {:ok, write_stream}} -> 468 | read_stream 469 | |> Stream.into(write_stream) 470 | |> Stream.run() 471 | 472 | # Only A support streaming -> Stream to memory and write when done 473 | {{:ok, read_stream}, {:error, ^destination_adapter}} -> 474 | Depot.write(destination_filesystem, destination_path, Enum.into(read_stream, [])) 475 | 476 | # Only B support streaming -> Load into memory and stream to B 477 | {{:error, ^source_adapter}, {:ok, write_stream}} -> 478 | with {:ok, contents} <- Depot.read(source_filesystem, source_path) do 479 | contents 480 | |> chunk(Keyword.get(opts, :chunk_size, 5 * 1024)) 481 | |> Enum.into(write_stream) 482 | end 483 | 484 | # Neither support streaming 485 | {{:error, ^source_adapter}, {:error, ^destination_adapter}} -> 486 | with {:ok, contents} <- Depot.read(source_filesystem, source_path) do 487 | Depot.write(destination_filesystem, destination_path, contents) 488 | end 489 | end 490 | rescue 491 | e -> {:error, e} 492 | end 493 | 494 | @doc false 495 | # Also used by the InMemory adapter and therefore not private 496 | def chunk("", _size), do: [] 497 | 498 | def chunk(binary, size) when byte_size(binary) >= size do 499 | {chunk, rest} = :erlang.split_binary(binary, size) 500 | [chunk | chunk(rest, size)] 501 | end 502 | 503 | def chunk(binary, _size), do: [binary] 504 | end 505 | -------------------------------------------------------------------------------- /lib/depot/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Adapter do 2 | @moduledoc """ 3 | Behaviour for how `Depot` adapters work. 4 | """ 5 | @type path :: Path.t() 6 | @type write_opts :: keyword 7 | @type stream_opts :: keyword 8 | @type directory_delete_opts :: keyword 9 | @type config :: struct 10 | 11 | @callback starts_processes() :: boolean 12 | @callback configure(keyword) :: {module(), config} 13 | @callback write(config, path, contents :: iodata(), write_opts) :: :ok | {:error, term} 14 | @callback write_stream(config, path, stream_opts) :: {:ok, Collectable.t()} | {:error, term} 15 | @callback read(config, path) :: {:ok, binary} | {:error, term} 16 | @callback read_stream(config, path, stream_opts) :: {:ok, Enumerable.t()} | {:error, term} 17 | @callback delete(config, path) :: :ok | {:error, term} 18 | @callback move(config, source :: path, destination :: path, write_opts) :: :ok | {:error, term} 19 | @callback copy(config, source :: path, destination :: path, write_opts) :: :ok | {:error, term} 20 | @callback copy( 21 | source_config :: config, 22 | source :: path, 23 | destination_config :: config, 24 | destination :: path, 25 | write_opts 26 | ) :: :ok | {:error, term} 27 | @callback file_exists(config, path) :: {:ok, :exists | :missing} | {:error, term} 28 | @callback list_contents(config, path) :: 29 | {:ok, [%Depot.Stat.Dir{} | %Depot.Stat.File{}]} | {:error, term} 30 | @callback create_directory(config, path, write_opts) :: :ok | {:error, term} 31 | @callback delete_directory(config, path, directory_delete_opts) :: :ok | {:error, term} 32 | @callback clear(config) :: :ok | {:error, term} 33 | @callback set_visibility(config, path, Depot.Visibility.t()) :: :ok | {:error, term} 34 | @callback visibility(config, path) :: {:ok, Depot.Visibility.t()} | {:error, term} 35 | end 36 | -------------------------------------------------------------------------------- /lib/depot/adapter/in_memory.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Adapter.InMemory do 2 | @moduledoc """ 3 | Depot Adapter using an `Agent` for in memory storage. 4 | 5 | ## Direct usage 6 | 7 | iex> filesystem = Depot.Adapter.InMemory.configure(name: InMemoryFileSystem) 8 | iex> start_supervised(filesystem) 9 | iex> :ok = Depot.write(filesystem, "test.txt", "Hello World") 10 | iex> {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 11 | 12 | ## Usage with a module 13 | 14 | defmodule InMemoryFileSystem do 15 | use Depot.Filesystem, 16 | adapter: Depot.Adapter.InMemory 17 | end 18 | 19 | start_supervised(InMemoryFileSystem) 20 | 21 | InMemoryFileSystem.write("test.txt", "Hello World") 22 | {:ok, "Hello World"} = InMemoryFileSystem.read("test.txt") 23 | """ 24 | 25 | defmodule AgentStream do 26 | @enforce_keys [:config, :path] 27 | defstruct config: nil, path: nil, chunk_size: 1024 28 | 29 | defimpl Enumerable do 30 | def reduce(%{config: config, path: path, chunk_size: chunk_size}, a, b) do 31 | case Depot.Adapter.InMemory.read(config, path) do 32 | {:ok, contents} -> 33 | contents 34 | |> Depot.chunk(chunk_size) 35 | |> reduce(a, b) 36 | 37 | _ -> 38 | {:halted, []} 39 | end 40 | end 41 | 42 | def reduce(_list, {:halt, acc}, _fun), do: {:halted, acc} 43 | def reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(list, &1, fun)} 44 | def reduce([], {:cont, acc}, _fun), do: {:done, acc} 45 | def reduce([head | tail], {:cont, acc}, fun), do: reduce(tail, fun.(head, acc), fun) 46 | 47 | def count(_), do: {:error, __MODULE__} 48 | def slice(_), do: {:error, __MODULE__} 49 | def member?(_, _), do: {:error, __MODULE__} 50 | end 51 | 52 | defimpl Collectable do 53 | def into(%{config: config, path: path} = stream) do 54 | original = 55 | case Depot.Adapter.InMemory.read(config, path) do 56 | {:ok, contents} -> contents 57 | _ -> "" 58 | end 59 | 60 | fun = fn 61 | list, {:cont, x} -> 62 | [x | list] 63 | 64 | list, :done -> 65 | contents = original <> IO.iodata_to_binary(:lists.reverse(list)) 66 | Depot.Adapter.InMemory.write(config, path, contents, []) 67 | stream 68 | 69 | _, :halt -> 70 | :ok 71 | end 72 | 73 | {[], fun} 74 | end 75 | end 76 | end 77 | 78 | use Agent 79 | 80 | defmodule Config do 81 | @moduledoc false 82 | defstruct name: nil 83 | end 84 | 85 | @behaviour Depot.Adapter 86 | 87 | @impl Depot.Adapter 88 | def starts_processes, do: true 89 | 90 | def start_link({__MODULE__, %Config{} = config}) do 91 | start_link(config) 92 | end 93 | 94 | def start_link(%Config{} = config) do 95 | Agent.start_link(fn -> {%{}, %{}} end, name: Depot.Registry.via(__MODULE__, config.name)) 96 | end 97 | 98 | @impl Depot.Adapter 99 | def configure(opts) do 100 | config = %Config{ 101 | name: Keyword.fetch!(opts, :name) 102 | } 103 | 104 | {__MODULE__, config} 105 | end 106 | 107 | @impl Depot.Adapter 108 | def write(config, path, contents, opts) do 109 | visibility = Keyword.get(opts, :visibility, :private) 110 | directory_visibility = Keyword.get(opts, :directory_visibility, :private) 111 | 112 | Agent.update(Depot.Registry.via(__MODULE__, config.name), fn state -> 113 | file = {IO.iodata_to_binary(contents), %{visibility: visibility}} 114 | directory = {%{}, %{visibility: directory_visibility}} 115 | put_in(state, accessor(path, directory), file) 116 | end) 117 | end 118 | 119 | @impl Depot.Adapter 120 | def write_stream(config, path, opts) do 121 | {:ok, 122 | %AgentStream{ 123 | config: config, 124 | path: path, 125 | chunk_size: Keyword.get(opts, :chunk_size, 1024) 126 | }} 127 | end 128 | 129 | @impl Depot.Adapter 130 | def read(config, path) do 131 | Agent.get(Depot.Registry.via(__MODULE__, config.name), fn state -> 132 | case get_in(state, accessor(path)) do 133 | {binary, _meta} when is_binary(binary) -> {:ok, binary} 134 | _ -> {:error, :enoent} 135 | end 136 | end) 137 | end 138 | 139 | @impl Depot.Adapter 140 | def read_stream(config, path, opts) do 141 | {:ok, 142 | %AgentStream{ 143 | config: config, 144 | path: path, 145 | chunk_size: Keyword.get(opts, :chunk_size, 1024) 146 | }} 147 | end 148 | 149 | @impl Depot.Adapter 150 | def delete(%Config{} = config, path) do 151 | Agent.update(Depot.Registry.via(__MODULE__, config.name), fn state -> 152 | {_, state} = pop_in(state, accessor(path)) 153 | state 154 | end) 155 | 156 | :ok 157 | end 158 | 159 | @impl Depot.Adapter 160 | def move(%Config{} = config, source, destination, opts) do 161 | visibility = Keyword.get(opts, :visibility, :private) 162 | directory_visibility = Keyword.get(opts, :directory_visibility, :private) 163 | 164 | Agent.get_and_update(Depot.Registry.via(__MODULE__, config.name), fn state -> 165 | case get_in(state, accessor(source)) do 166 | {binary, _meta} when is_binary(binary) -> 167 | file = {binary, %{visibility: visibility}} 168 | directory = {%{}, %{visibility: directory_visibility}} 169 | 170 | {_, state} = 171 | state |> put_in(accessor(destination, directory), file) |> pop_in(accessor(source)) 172 | 173 | {:ok, state} 174 | 175 | _ -> 176 | {{:error, :enoent}, state} 177 | end 178 | end) 179 | end 180 | 181 | @impl Depot.Adapter 182 | def copy(%Config{} = config, source, destination, opts) do 183 | visibility = Keyword.get(opts, :visibility, :private) 184 | directory_visibility = Keyword.get(opts, :directory_visibility, :private) 185 | 186 | Agent.get_and_update(Depot.Registry.via(__MODULE__, config.name), fn state -> 187 | case get_in(state, accessor(source)) do 188 | {binary, _meta} when is_binary(binary) -> 189 | file = {binary, %{visibility: visibility}} 190 | directory = {%{}, %{visibility: directory_visibility}} 191 | {:ok, put_in(state, accessor(destination, directory), file)} 192 | 193 | _ -> 194 | {{:error, :enoent}, state} 195 | end 196 | end) 197 | end 198 | 199 | @impl Depot.Adapter 200 | def copy( 201 | %Config{} = _source_config, 202 | _source, 203 | %Config{} = _destination_config, 204 | _destination, 205 | _opts 206 | ) do 207 | {:error, :unsupported} 208 | end 209 | 210 | @impl Depot.Adapter 211 | def file_exists(%Config{} = config, path) do 212 | Agent.get(Depot.Registry.via(__MODULE__, config.name), fn state -> 213 | case get_in(state, accessor(path)) do 214 | {binary, _meta} when is_binary(binary) -> {:ok, :exists} 215 | _ -> {:ok, :missing} 216 | end 217 | end) 218 | end 219 | 220 | @impl Depot.Adapter 221 | def list_contents(%Config{} = config, path) do 222 | contents = 223 | Agent.get(Depot.Registry.via(__MODULE__, config.name), fn state -> 224 | paths = 225 | case get_in(state, accessor(path)) do 226 | {%{} = map, _meta} -> map 227 | _ -> %{} 228 | end 229 | 230 | for {path, {content, meta}} <- paths do 231 | struct = 232 | case content do 233 | %{} -> %Depot.Stat.Dir{size: 0} 234 | bin when is_binary(bin) -> %Depot.Stat.File{size: byte_size(bin)} 235 | end 236 | 237 | struct!(struct, name: path, mtime: 0, visibility: meta.visibility) 238 | end 239 | end) 240 | 241 | {:ok, contents} 242 | end 243 | 244 | @impl Depot.Adapter 245 | def create_directory(%Config{} = config, path, opts) do 246 | directory_visibility = Keyword.get(opts, :directory_visibility, :private) 247 | directory = {%{}, %{visibility: directory_visibility}} 248 | 249 | Agent.update(Depot.Registry.via(__MODULE__, config.name), fn state -> 250 | put_in(state, accessor(path, directory), directory) 251 | end) 252 | end 253 | 254 | @impl Depot.Adapter 255 | def delete_directory(%Config{} = config, path, opts) do 256 | recursive? = Keyword.get(opts, :recursive, false) 257 | 258 | Agent.get_and_update(Depot.Registry.via(__MODULE__, config.name), fn state -> 259 | case {recursive?, get_in(state, accessor(path))} do 260 | {_, nil} -> 261 | {:ok, state} 262 | 263 | {recursive?, {map, _meta}} when is_map(map) and (map_size(map) == 0 or recursive?) -> 264 | {_, state} = pop_in(state, accessor(path)) 265 | {:ok, state} 266 | 267 | _ -> 268 | {{:error, :eexist}, state} 269 | end 270 | end) 271 | end 272 | 273 | @impl Depot.Adapter 274 | def clear(%Config{} = config) do 275 | Agent.update(Depot.Registry.via(__MODULE__, config.name), fn _ -> {%{}, %{}} end) 276 | end 277 | 278 | @impl Depot.Adapter 279 | def set_visibility(%Config{} = config, path, visibility) do 280 | Agent.get_and_update(Depot.Registry.via(__MODULE__, config.name), fn state -> 281 | case get_in(state, accessor(path)) do 282 | {_, _} -> 283 | state = 284 | update_in(state, accessor(path), fn {contents, meta} -> 285 | {contents, Map.put(meta, :visibility, visibility)} 286 | end) 287 | 288 | {:ok, state} 289 | 290 | _ -> 291 | {{:error, :enoent}, state} 292 | end 293 | end) 294 | end 295 | 296 | @impl Depot.Adapter 297 | def visibility(%Config{} = config, path) do 298 | Agent.get(Depot.Registry.via(__MODULE__, config.name), fn state -> 299 | case get_in(state, accessor(path)) do 300 | {_, %{visibility: visibility}} -> {:ok, visibility} 301 | _ -> {:error, :enoent} 302 | end 303 | end) 304 | end 305 | 306 | defp accessor(path, default \\ nil) when is_binary(path) do 307 | path 308 | |> Path.absname("/") 309 | |> Path.split() 310 | |> do_accessor([], default) 311 | |> Enum.reverse() 312 | end 313 | 314 | defp do_accessor([segment], acc, default) do 315 | [Access.key(segment, default), Access.elem(0) | acc] 316 | end 317 | 318 | defp do_accessor([segment | rest], acc, default) do 319 | intermediate_default = default || {%{}, %{}} 320 | do_accessor(rest, [Access.key(segment, intermediate_default), Access.elem(0) | acc], default) 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /lib/depot/adapter/local.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Adapter.Local do 2 | @moduledoc """ 3 | Depot Adapter for the local filesystem. 4 | 5 | ## Direct usage 6 | 7 | iex> prefix = System.tmp_dir!() 8 | iex> filesystem = Depot.Adapter.Local.configure(prefix: prefix) 9 | iex> :ok = Depot.write(filesystem, "test.txt", "Hello World") 10 | iex> {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 11 | 12 | ## Usage with a module 13 | 14 | defmodule LocalFileSystem do 15 | use Depot.Filesystem, 16 | adapter: Depot.Adapter.Local, 17 | prefix: prefix 18 | end 19 | 20 | LocalFileSystem.write("test.txt", "Hello World") 21 | {:ok, "Hello World"} = LocalFileSystem.read("test.txt") 22 | 23 | ## Usage with Streams 24 | 25 | The following options are available for streams: 26 | 27 | * `:chunk_size` - When reading, the amount to read, 28 | by `:line` (default) or by a given number of bytes. 29 | 30 | * `:modes` - A list of modes to use when opening the file 31 | for reading. For more information, see the docs for 32 | `File.stream!/3`. 33 | 34 | ### Examples 35 | 36 | {:ok, %File.Stream{}} = Depot.read_stream(filesystem, "test.txt") 37 | 38 | # with custom read chunk size 39 | {:ok, %File.Stream{line_or_bytes: 1_024, ...}} = Depot.read_stream(filesystem, "test.txt", chunk_size: 1_024) 40 | 41 | # with custom file read modes 42 | {:ok, %File.Stream{mode: [{:encoding, :utf8}, :binary], ...}} = Depot.read_stream(filesystem, "test.txt", modes: [encoding: :utf8]) 43 | 44 | """ 45 | use Bitwise, only_operators: true 46 | alias Depot.Visibility.UnixVisibilityConverter 47 | alias Depot.Visibility.PortableUnixVisibilityConverter, as: DefaultVisibilityConverter 48 | 49 | defmodule Config do 50 | @moduledoc false 51 | 52 | @type t :: %__MODULE__{ 53 | prefix: Path.t(), 54 | converter: UnixVisibilityConverter.t(), 55 | visibility: UnixVisibilityConverter.config() 56 | } 57 | 58 | defstruct prefix: nil, converter: nil, visibility: nil 59 | end 60 | 61 | @behaviour Depot.Adapter 62 | 63 | @impl Depot.Adapter 64 | def starts_processes, do: false 65 | 66 | @impl Depot.Adapter 67 | def configure(opts) do 68 | visibility_config = Keyword.get(opts, :visibility, []) 69 | converter = Keyword.get(visibility_config, :converter, DefaultVisibilityConverter) 70 | visibility = visibility_config |> Keyword.drop([:converter]) |> converter.config() 71 | 72 | config = %Config{ 73 | prefix: Keyword.fetch!(opts, :prefix), 74 | converter: converter, 75 | visibility: visibility 76 | } 77 | 78 | {__MODULE__, config} 79 | end 80 | 81 | @impl Depot.Adapter 82 | def write(%Config{} = config, path, contents, opts) do 83 | path = full_path(config, path) 84 | 85 | mode = 86 | with {:ok, visibility} <- Keyword.fetch(opts, :visibility) do 87 | mode = config.converter.for_file(config.visibility, visibility) 88 | {:ok, mode} 89 | end 90 | 91 | with :ok <- ensure_directory(config, Path.dirname(path), opts), 92 | :ok <- File.write(path, contents), 93 | :ok <- maybe_chmod(path, mode) do 94 | :ok 95 | end 96 | end 97 | 98 | @impl Depot.Adapter 99 | def write_stream(%Config{} = config, path, opts) do 100 | modes = opts[:modes] || [] 101 | line_or_bytes = opts[:chunk_size] || :line 102 | {:ok, File.stream!(full_path(config, path), modes, line_or_bytes)} 103 | rescue 104 | e -> {:error, e} 105 | end 106 | 107 | @impl Depot.Adapter 108 | def read(%Config{} = config, path) do 109 | File.read(full_path(config, path)) 110 | end 111 | 112 | @impl Depot.Adapter 113 | def read_stream(%Config{} = config, path, opts) do 114 | modes = opts[:modes] || [] 115 | line_or_bytes = opts[:chunk_size] || :line 116 | {:ok, File.stream!(full_path(config, path), modes, line_or_bytes)} 117 | rescue 118 | e -> {:error, e} 119 | end 120 | 121 | @impl Depot.Adapter 122 | def delete(%Config{} = config, path) do 123 | with {:error, :enoent} <- File.rm(full_path(config, path)), do: :ok 124 | end 125 | 126 | @impl Depot.Adapter 127 | def move(%Config{} = config, source, destination, opts) do 128 | source = full_path(config, source) 129 | destination = full_path(config, destination) 130 | 131 | with :ok <- ensure_directory(config, Path.dirname(destination), opts) do 132 | File.rename(source, destination) 133 | end 134 | end 135 | 136 | @impl Depot.Adapter 137 | def copy(%Config{} = config, source, destination, opts) do 138 | source = full_path(config, source) 139 | destination = full_path(config, destination) 140 | 141 | with :ok <- ensure_directory(config, Path.dirname(destination), opts) do 142 | File.cp(source, destination) 143 | end 144 | end 145 | 146 | @impl Depot.Adapter 147 | def copy( 148 | %Config{} = source_config, 149 | source, 150 | %Config{} = destination_config, 151 | destination, 152 | opts 153 | ) do 154 | source = full_path(source_config, source) 155 | destination = full_path(destination_config, destination) 156 | 157 | with :ok <- ensure_directory(destination_config, Path.dirname(destination), opts) do 158 | File.cp(source, destination) 159 | end 160 | end 161 | 162 | @impl Depot.Adapter 163 | def file_exists(%Config{} = config, path) do 164 | case File.exists?(full_path(config, path)) do 165 | true -> {:ok, :exists} 166 | false -> {:ok, :missing} 167 | end 168 | end 169 | 170 | @impl Depot.Adapter 171 | def list_contents(%Config{} = config, path) do 172 | full_path = full_path(config, path) 173 | 174 | with {:ok, files} <- File.ls(full_path) do 175 | contents = 176 | for file <- files, 177 | {:ok, stat} = File.stat(Path.join(full_path, file), time: :posix), 178 | stat.type in [:directory, :regular] do 179 | struct = 180 | case stat.type do 181 | :directory -> Depot.Stat.Dir 182 | :regular -> Depot.Stat.File 183 | end 184 | 185 | struct!(struct, 186 | name: file, 187 | size: stat.size, 188 | mtime: stat.mtime, 189 | visibility: visibility_for_mode(config, stat.type, stat.mode) 190 | ) 191 | end 192 | 193 | {:ok, contents} 194 | end 195 | end 196 | 197 | @impl Depot.Adapter 198 | def create_directory(%Config{} = config, path, opts) do 199 | path = full_path(config, path) 200 | ensure_directory(config, path, opts) 201 | end 202 | 203 | @impl Depot.Adapter 204 | def delete_directory(%Config{} = config, path, opts) do 205 | path = full_path(config, path) 206 | 207 | if Keyword.get(opts, :recursive, false) do 208 | with {:ok, _} <- File.rm_rf(path), do: :ok 209 | else 210 | File.rmdir(path) 211 | end 212 | end 213 | 214 | @impl Depot.Adapter 215 | def clear(%Config{} = config) do 216 | with {:ok, contents} <- list_contents(%Config{} = config, ".") do 217 | Enum.reduce_while(contents, :ok, fn dir_or_file, :ok -> 218 | case clear_dir_or_file(config, dir_or_file) do 219 | :ok -> {:cont, :ok} 220 | err -> {:halt, err} 221 | end 222 | end) 223 | end 224 | end 225 | 226 | @impl Depot.Adapter 227 | def set_visibility(%Config{} = config, path, visibility) do 228 | path = full_path(config, path) 229 | 230 | mode = 231 | if File.dir?(path) do 232 | config.converter.for_directory(config.visibility, visibility) 233 | else 234 | config.converter.for_file(config.visibility, visibility) 235 | end 236 | 237 | File.chmod(path, mode) 238 | end 239 | 240 | @impl Depot.Adapter 241 | def visibility(%Config{} = config, path) do 242 | path = full_path(config, path) 243 | 244 | with {:ok, %{mode: mode, type: type}} <- File.stat(path) do 245 | {:ok, visibility_for_mode(config, type, mode)} 246 | end 247 | end 248 | 249 | defp visibility_for_mode(config, type, mode) do 250 | mode = mode &&& 0o777 251 | 252 | case type do 253 | :directory -> config.converter.from_directory(config.visibility, mode) 254 | _ -> config.converter.from_file(config.visibility, mode) 255 | end 256 | end 257 | 258 | defp clear_dir_or_file(config, %Depot.Stat.Dir{name: dir}), 259 | do: delete_directory(config, dir, recursive: true) 260 | 261 | defp clear_dir_or_file(config, %Depot.Stat.File{name: name}), 262 | do: delete(config, name) 263 | 264 | defp full_path(config, path) do 265 | Depot.RelativePath.join_prefix(config.prefix, path) 266 | end 267 | 268 | defp ensure_directory(config, path, opts) do 269 | mode = 270 | with {:ok, visibility} <- Keyword.fetch(opts, :directory_visibility) do 271 | mode = config.converter.for_directory(config.visibility, visibility) 272 | {:ok, mode} 273 | end 274 | 275 | path 276 | |> IO.chardata_to_string() 277 | |> Path.join("/") 278 | |> do_mkdir_p(mode) 279 | end 280 | 281 | defp do_mkdir_p(path, mode) do 282 | with :missing <- existing_directory(path), 283 | parent = Path.dirname(path), 284 | :ok <- infinite_loop_protect(path), 285 | :ok <- do_mkdir_p(parent, mode), 286 | :ok <- :file.make_dir(path) do 287 | maybe_chmod(path, mode) 288 | end 289 | end 290 | 291 | def existing_directory(path) do 292 | if File.dir?(path), do: :ok, else: :missing 293 | end 294 | 295 | defp infinite_loop_protect(path) do 296 | if Path.dirname(path) != path, do: :ok, else: {:error, :einval} 297 | end 298 | 299 | defp maybe_chmod(path, {:ok, mode}), do: File.chmod(path, mode) 300 | defp maybe_chmod(_path, :error), do: :ok 301 | end 302 | -------------------------------------------------------------------------------- /lib/depot/adapter_test.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.AdapterTest do 2 | defmacro in_list(list, match) do 3 | quote do 4 | Enum.any?(unquote(list), &match?(unquote(match), &1)) 5 | end 6 | end 7 | 8 | defp tests do 9 | quote do 10 | test "user can write to filesystem", %{filesystem: filesystem} do 11 | assert :ok = Depot.write(filesystem, "test.txt", "Hello World") 12 | end 13 | 14 | test "user can overwrite a file on the filesystem", %{filesystem: filesystem} do 15 | :ok = Depot.write(filesystem, "test.txt", "Old text") 16 | assert :ok = Depot.write(filesystem, "test.txt", "Hello World") 17 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 18 | end 19 | 20 | test "user can stream to a filesystem", %{filesystem: {adapter, _} = filesystem} do 21 | case Depot.write_stream(filesystem, "test.txt") do 22 | {:ok, stream} -> 23 | Enum.into(["Hello", " ", "World"], stream) 24 | 25 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 26 | 27 | {:error, ^adapter} -> 28 | :ok 29 | end 30 | end 31 | 32 | test "user can check if files exist on a filesystem", %{filesystem: filesystem} do 33 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 34 | 35 | assert {:ok, :exists} = Depot.file_exists(filesystem, "test.txt") 36 | assert {:ok, :missing} = Depot.file_exists(filesystem, "not-test.txt") 37 | end 38 | 39 | test "user can read from filesystem", %{filesystem: filesystem} do 40 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 41 | 42 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 43 | end 44 | 45 | test "user can stream from filesystem", %{filesystem: {adapter, _} = filesystem} do 46 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 47 | 48 | case Depot.read_stream(filesystem, "test.txt") do 49 | {:ok, stream} -> 50 | assert Enum.into(stream, <<>>) == "Hello World" 51 | 52 | {:error, ^adapter} -> 53 | :ok 54 | end 55 | end 56 | 57 | test "user can stream in a certain chunk size", %{filesystem: {adapter, _} = filesystem} do 58 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 59 | 60 | case Depot.read_stream(filesystem, "test.txt", chunk_size: 2) do 61 | {:ok, stream} -> 62 | assert ["He" | _] = Enum.into(stream, []) 63 | 64 | {:error, ^adapter} -> 65 | :ok 66 | end 67 | end 68 | 69 | test "user can try to read a non-existing file from filesystem", %{filesystem: filesystem} do 70 | assert {:error, :enoent} = Depot.read(filesystem, "test.txt") 71 | end 72 | 73 | test "user can delete from filesystem", %{filesystem: filesystem} do 74 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 75 | :ok = Depot.delete(filesystem, "test.txt") 76 | 77 | assert {:error, _} = Depot.read(filesystem, "test.txt") 78 | end 79 | 80 | test "user can delete a non-existing file from filesystem", %{filesystem: filesystem} do 81 | assert :ok = Depot.delete(filesystem, "test.txt") 82 | end 83 | 84 | test "user can move files", %{filesystem: filesystem} do 85 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 86 | :ok = Depot.move(filesystem, "test.txt", "not-test.txt") 87 | 88 | assert {:error, _} = Depot.read(filesystem, "test.txt") 89 | assert {:ok, "Hello World"} = Depot.read(filesystem, "not-test.txt") 90 | end 91 | 92 | test "user can try to move a non-existing file", %{filesystem: filesystem} do 93 | assert {:error, :enoent} = Depot.move(filesystem, "test.txt", "not-test.txt") 94 | end 95 | 96 | test "user can copy files", %{filesystem: filesystem} do 97 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 98 | :ok = Depot.copy(filesystem, "test.txt", "not-test.txt") 99 | 100 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 101 | assert {:ok, "Hello World"} = Depot.read(filesystem, "not-test.txt") 102 | end 103 | 104 | test "user can try to copy a non-existing file", %{filesystem: filesystem} do 105 | assert {:error, :enoent} = Depot.copy(filesystem, "test.txt", "not-test.txt") 106 | end 107 | 108 | test "user can list files and folders", %{filesystem: filesystem} do 109 | :ok = Depot.create_directory(filesystem, "test/") 110 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 111 | :ok = Depot.write(filesystem, "test-1.txt", "Hello World") 112 | :ok = Depot.write(filesystem, "folder/test-1.txt", "Hello World") 113 | 114 | {:ok, list} = Depot.list_contents(filesystem, ".") 115 | 116 | assert in_list(list, %Depot.Stat.Dir{name: "test"}) 117 | assert in_list(list, %Depot.Stat.Dir{name: "folder"}) 118 | assert in_list(list, %Depot.Stat.File{name: "test.txt"}) 119 | assert in_list(list, %Depot.Stat.File{name: "test-1.txt"}) 120 | 121 | refute in_list(list, %Depot.Stat.File{name: "folder/test-1.txt"}) 122 | 123 | assert length(list) == 4 124 | end 125 | 126 | test "directory listings include visibility", %{filesystem: filesystem} do 127 | :ok = Depot.write(filesystem, "visible.txt", "Hello World", visibility: :public) 128 | :ok = Depot.write(filesystem, "invisible.txt", "Hello World", visibility: :private) 129 | :ok = Depot.create_directory(filesystem, "visible-dir/", directory_visibility: :public) 130 | :ok = Depot.create_directory(filesystem, "invisible-dir/", directory_visibility: :private) 131 | 132 | {:ok, list} = Depot.list_contents(filesystem, ".") 133 | 134 | assert in_list(list, %Depot.Stat.Dir{name: "visible-dir", visibility: :public}) 135 | assert in_list(list, %Depot.Stat.Dir{name: "invisible-dir", visibility: :private}) 136 | assert in_list(list, %Depot.Stat.File{name: "visible.txt", visibility: :public}) 137 | assert in_list(list, %Depot.Stat.File{name: "invisible.txt", visibility: :private}) 138 | 139 | assert length(list) == 4 140 | end 141 | 142 | test "user can create directories", %{filesystem: filesystem} do 143 | assert :ok = Depot.create_directory(filesystem, "test/") 144 | assert :ok = Depot.create_directory(filesystem, "test/nested/folder/") 145 | end 146 | 147 | test "user can delete directories", %{filesystem: filesystem} do 148 | :ok = Depot.create_directory(filesystem, "test/") 149 | assert :ok = Depot.delete_directory(filesystem, "test/") 150 | end 151 | 152 | test "non empty directories are not deleted by default", %{filesystem: filesystem} do 153 | :ok = Depot.write(filesystem, "test/test.txt", "Hello World") 154 | assert {:error, _} = Depot.delete_directory(filesystem, "test/") 155 | end 156 | 157 | test "non empty directories are deleted with the recursive flag set", %{ 158 | filesystem: filesystem 159 | } do 160 | :ok = Depot.write(filesystem, "test/test.txt", "Hello World") 161 | assert :ok = Depot.delete_directory(filesystem, "test/", recursive: true) 162 | 163 | :ok = Depot.create_directory(filesystem, "test/nested/folder/") 164 | assert :ok = Depot.delete_directory(filesystem, "test/", recursive: true) 165 | end 166 | 167 | test "files in deleted directories are no longer available", %{filesystem: filesystem} do 168 | :ok = Depot.write(filesystem, "test/test.txt", "Hello World") 169 | assert :ok = Depot.delete_directory(filesystem, "test/", recursive: true) 170 | assert {:ok, :missing} = Depot.file_exists(filesystem, "not-test.txt") 171 | end 172 | 173 | test "non filesystem can be cleared", %{ 174 | filesystem: filesystem 175 | } do 176 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 177 | :ok = Depot.write(filesystem, "test/test.txt", "Hello World") 178 | :ok = Depot.create_directory(filesystem, "test/nested/folder/") 179 | 180 | assert :ok = Depot.clear(filesystem) 181 | 182 | assert {:ok, :missing} = Depot.file_exists(filesystem, "test.txt") 183 | assert {:ok, :missing} = Depot.file_exists(filesystem, "test/test.txt") 184 | assert {:ok, :missing} = Depot.file_exists(filesystem, "test/") 185 | end 186 | 187 | test "set visibility", %{filesystem: filesystem} do 188 | :ok = 189 | Depot.write(filesystem, "folder/file.txt", "Hello World", 190 | visibility: :public, 191 | directory_visibility: :public 192 | ) 193 | 194 | assert :ok = Depot.set_visibility(filesystem, "folder/", :private) 195 | assert {:ok, :private} = Depot.visibility(filesystem, "folder/") 196 | 197 | assert :ok = Depot.set_visibility(filesystem, "folder/file.txt", :private) 198 | assert {:ok, :private} = Depot.visibility(filesystem, "folder/file.txt") 199 | end 200 | 201 | test "visibility", %{filesystem: filesystem} do 202 | :ok = 203 | Depot.write(filesystem, "public/file.txt", "Hello World", 204 | visibility: :private, 205 | directory_visibility: :public 206 | ) 207 | 208 | :ok = 209 | Depot.write(filesystem, "private/file.txt", "Hello World", 210 | visibility: :public, 211 | directory_visibility: :private 212 | ) 213 | 214 | assert {:ok, :public} = Depot.visibility(filesystem, ".") 215 | assert {:ok, :public} = Depot.visibility(filesystem, "public/") 216 | assert {:ok, :private} = Depot.visibility(filesystem, "public/file.txt") 217 | assert {:ok, :private} = Depot.visibility(filesystem, "private/") 218 | assert {:ok, :public} = Depot.visibility(filesystem, "private/file.txt") 219 | end 220 | end 221 | end 222 | 223 | defmacro adapter_test(block) do 224 | quote do 225 | describe "common adapter tests" do 226 | setup unquote(block) 227 | 228 | import Depot.AdapterTest, only: [in_list: 2] 229 | unquote(tests()) 230 | end 231 | end 232 | end 233 | 234 | defmacro adapter_test(context, block) do 235 | quote do 236 | describe "common adapter tests" do 237 | setup unquote(context), unquote(block) 238 | 239 | import Depot.AdapterTest, only: [in_list: 2] 240 | unquote(tests()) 241 | end 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /lib/depot/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_type, _args) do 6 | children = [ 7 | Depot.Registry 8 | ] 9 | 10 | Supervisor.start_link(children, strategy: :one_for_one) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/depot/filesystem.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Filesystem do 2 | @moduledoc """ 3 | Behaviour of a `Depot` filesystem. 4 | """ 5 | @callback write(path :: Path.t(), contents :: binary, opts :: keyword()) :: :ok | {:error, term} 6 | @callback read(path :: Path.t(), opts :: keyword()) :: {:ok, binary} | {:error, term} 7 | @callback read_stream(path :: Path.t(), opts :: keyword()) :: 8 | {:ok, Enumerable.t()} | {:error, term} 9 | @callback delete(path :: Path.t(), opts :: keyword()) :: :ok | {:error, term} 10 | @callback move(source :: Path.t(), destination :: Path.t(), opts :: keyword()) :: 11 | :ok | {:error, term} 12 | @callback copy(source :: Path.t(), destination :: Path.t(), opts :: keyword()) :: 13 | :ok | {:error, term} 14 | @callback file_exists(path :: Path.t(), opts :: keyword()) :: 15 | {:ok, :exists | :missing} | {:error, term} 16 | @callback list_contents(path :: Path.t(), opts :: keyword()) :: 17 | {:ok, [%Depot.Stat.Dir{} | %Depot.Stat.File{}]} | {:error, term} 18 | 19 | @doc false 20 | @spec __using__(Macro.t()) :: Macro.t() 21 | defmacro __using__(opts) do 22 | quote bind_quoted: [opts: opts] do 23 | @behaviour Depot.Filesystem 24 | {adapter, opts} = Depot.Filesystem.parse_opts(__MODULE__, opts) 25 | @adapter adapter 26 | @opts opts 27 | @key {Depot.Filesystem, __MODULE__} 28 | 29 | def init do 30 | filesystem = 31 | @opts 32 | |> Depot.Filesystem.merge_app_env(__MODULE__) 33 | |> @adapter.configure() 34 | 35 | :persistent_term.put(@key, filesystem) 36 | 37 | filesystem 38 | end 39 | 40 | def __filesystem__ do 41 | :persistent_term.get(@key, init()) 42 | end 43 | 44 | if adapter.starts_processes() do 45 | def child_spec(_) do 46 | Supervisor.child_spec(__filesystem__(), %{}) 47 | end 48 | end 49 | 50 | @impl true 51 | def write(path, contents, opts \\ []), 52 | do: Depot.write(__filesystem__(), path, contents, opts) 53 | 54 | @impl true 55 | def read(path, opts \\ []), 56 | do: Depot.read(__filesystem__(), path, opts) 57 | 58 | @impl true 59 | def read_stream(path, opts \\ []), 60 | do: Depot.read_stream(__filesystem__(), path, opts) 61 | 62 | @impl true 63 | def delete(path, opts \\ []), 64 | do: Depot.delete(__filesystem__(), path, opts) 65 | 66 | @impl true 67 | def move(source, destination, opts \\ []), 68 | do: Depot.move(__filesystem__(), source, destination, opts) 69 | 70 | @impl true 71 | def copy(source, destination, opts \\ []), 72 | do: Depot.copy(__filesystem__(), source, destination, opts) 73 | 74 | @impl true 75 | def file_exists(path, opts \\ []), 76 | do: Depot.file_exists(__filesystem__(), path, opts) 77 | 78 | @impl true 79 | def list_contents(path, opts \\ []), 80 | do: Depot.list_contents(__filesystem__(), path, opts) 81 | end 82 | end 83 | 84 | def parse_opts(module, opts) do 85 | opts 86 | |> merge_app_env(module) 87 | |> Keyword.put_new(:name, module) 88 | |> Keyword.pop!(:adapter) 89 | end 90 | 91 | def merge_app_env(opts, module) do 92 | case Keyword.fetch(opts, :otp_app) do 93 | {:ok, otp_app} -> 94 | config = Application.get_env(otp_app, module, []) 95 | Keyword.merge(opts, config) 96 | 97 | :error -> 98 | opts 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/depot/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Registry do 2 | @moduledoc """ 3 | Elixir registry to register adapter instances on for adapters, which need processes. 4 | 5 | ## Registration 6 | 7 | Register instances with the via tuple of: `Depot.Registry.via(adapter, name)` 8 | 9 | """ 10 | 11 | @doc false 12 | def child_spec(_) do 13 | Registry.child_spec(keys: :unique, name: __MODULE__) 14 | end 15 | 16 | @doc false 17 | def via(adapter, name) do 18 | {:via, Registry, {__MODULE__, {adapter, name}}} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/depot/relative_path.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.RelativePath do 2 | @moduledoc false 3 | @type t :: Path.t() 4 | 5 | @slash [?/, ?\\] 6 | 7 | @spec normalize(binary) :: {:ok, t} | {:error, term} 8 | def normalize(path) do 9 | case relative?(path) do 10 | true -> 11 | case expand(path) do 12 | {:ok, expanded} -> {:ok, expanded} 13 | {:error, :traversal} -> {:error, {:path, :traversal}} 14 | end 15 | 16 | false -> 17 | {:error, {:path, :absolute}} 18 | end 19 | end 20 | 21 | @spec relative?(binary) :: boolean 22 | def relative?(<>) when c1 in @slash and c2 in @slash, 23 | do: false 24 | 25 | def relative?(<<_letter, ?:, slash, _::binary>>) when slash in @slash, 26 | do: false 27 | 28 | def relative?(<<_letter, ?:, _::binary>>), do: false 29 | def relative?(<>) when slash in @slash, do: false 30 | def relative?(path) when is_binary(path), do: true 31 | 32 | @spec expand(t) :: {:ok, t} | {:error, term} 33 | def expand(<<"../", _::binary>>), do: {:error, :traversal} 34 | 35 | def expand(path) do 36 | try do 37 | expanded = 38 | path 39 | |> Path.relative_to("/") 40 | |> expand_dot() 41 | 42 | {:ok, expanded} 43 | catch 44 | :traversal -> {:error, :traversal} 45 | end 46 | end 47 | 48 | defp expand_dot(<>) when letter in ?A..?Z, do: expand_dot(rest) 49 | defp expand_dot(path), do: expand_dot(:binary.split(path, "/", [:global]), []) 50 | defp expand_dot([".." | t], [_, _ | acc]), do: expand_dot(t, acc) 51 | defp expand_dot([".." | _t], []), do: throw(:traversal) 52 | defp expand_dot(["." | t], acc), do: expand_dot(t, acc) 53 | defp expand_dot([h | t], acc), do: expand_dot(t, ["/", h | acc]) 54 | defp expand_dot([], []), do: "" 55 | defp expand_dot([], ["/" | acc]), do: IO.iodata_to_binary(:lists.reverse(acc)) 56 | 57 | @spec join_prefix(Path.t(), t) :: Path.t() 58 | def join_prefix(prefix, path) do 59 | join = Path.join(prefix, path) 60 | 61 | case String.last(path) do 62 | "/" -> join <> "/" 63 | _ -> join 64 | end 65 | end 66 | 67 | @spec strip_prefix(Path.t(), t) :: Path.t() 68 | def strip_prefix(prefix, path) do 69 | Path.relative_to(path, prefix) 70 | end 71 | 72 | def assert_directory(path) do 73 | if String.ends_with?(path, "/") do 74 | {:ok, path} 75 | else 76 | {:error, :enotdir} 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/depot/stat/dir.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Stat.Dir do 2 | defstruct name: nil, size: nil, mtime: nil, visibility: nil 3 | end 4 | -------------------------------------------------------------------------------- /lib/depot/stat/file.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Stat.File do 2 | defstruct name: nil, size: nil, mtime: nil, visibility: nil 3 | end 4 | -------------------------------------------------------------------------------- /lib/depot/visibility/portable_unix_visibility_converter.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Visibility.PortableUnixVisibilityConverter do 2 | @moduledoc """ 3 | `Depot.Visibility.UnixVisibilityConverter` supporting `Depot.Visibility.portable()`. 4 | 5 | This is a good default visibility converter for adapters using unix based permissions. 6 | """ 7 | alias Depot.Visibility.UnixVisibilityConverter 8 | 9 | defmodule Config do 10 | @moduledoc false 11 | 12 | @type t :: %__MODULE__{ 13 | file_public: UnixVisibilityConverter.permission(), 14 | file_private: UnixVisibilityConverter.permission(), 15 | directory_public: UnixVisibilityConverter.permission(), 16 | directory_private: UnixVisibilityConverter.permission() 17 | } 18 | 19 | defstruct file_public: 0o644, 20 | file_private: 0o600, 21 | directory_public: 0o755, 22 | directory_private: 0o700 23 | end 24 | 25 | @behaviour UnixVisibilityConverter 26 | 27 | @impl UnixVisibilityConverter 28 | def config(config) do 29 | struct!(%Config{}, config) 30 | end 31 | 32 | @impl UnixVisibilityConverter 33 | def for_file(%Config{} = config, visibility) do 34 | with {:ok, visibility} <- Depot.Visibility.guard_portable(visibility) do 35 | case visibility do 36 | :public -> config.file_public 37 | :private -> config.file_private 38 | end 39 | end 40 | end 41 | 42 | @impl UnixVisibilityConverter 43 | def for_directory(%Config{} = config, visibility) do 44 | with {:ok, visibility} <- Depot.Visibility.guard_portable(visibility) do 45 | case visibility do 46 | :public -> config.directory_public 47 | :private -> config.directory_private 48 | end 49 | end 50 | end 51 | 52 | @impl UnixVisibilityConverter 53 | def from_file(%Config{} = config, permission) do 54 | cond do 55 | permission === config.file_public -> :public 56 | permission === config.file_private -> :private 57 | true -> :public 58 | end 59 | end 60 | 61 | @impl UnixVisibilityConverter 62 | def from_directory(%Config{} = config, permission) do 63 | cond do 64 | permission === config.directory_public -> :public 65 | permission === config.directory_private -> :private 66 | true -> :public 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/depot/visibility/unix_visibility_converter.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Visibility.UnixVisibilityConverter do 2 | @moduledoc """ 3 | Visibility converter behaviour for unix based systems. 4 | """ 5 | @type t :: module 6 | @type permission :: non_neg_integer() 7 | @type config :: struct 8 | 9 | @callback config(keyword) :: config 10 | 11 | @callback for_file(config, Depot.Visibility.t()) :: {:ok, permission} | :error 12 | @callback for_directory(config, Depot.Visibility.t()) :: {:ok, permission} | :error 13 | 14 | @callback from_file(config, permission) :: Depot.Visibility.t() 15 | @callback from_directory(config, permission) :: Depot.Visibility.t() 16 | end 17 | -------------------------------------------------------------------------------- /lib/depot/visibility/visibility.ex: -------------------------------------------------------------------------------- 1 | defmodule Depot.Visibility do 2 | @type t :: portable | custom 3 | @type portable :: :public | :private 4 | @type custom :: term 5 | 6 | @spec portable?(any) :: boolean 7 | def portable?(:public), do: true 8 | def portable?(:private), do: true 9 | def portable?(_), do: false 10 | 11 | @spec guard_portable(any) :: {:ok, Depot.Visibility.portable()} | :error 12 | def guard_portable(visibility) do 13 | if portable?(visibility) do 14 | {:ok, visibility} 15 | else 16 | :error 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Depot.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :depot, 7 | version: "0.5.2", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | package: package(), 13 | name: "Depot", 14 | source_url: "https://github.com/elixir-depot/depot", 15 | docs: docs() 16 | ] 17 | end 18 | 19 | defp description() do 20 | "A filesystem abstraction for elixir." 21 | end 22 | 23 | defp package() do 24 | [ 25 | # These are the default files included in the package 26 | files: ~w(lib mix.exs README* LICENSE* CHANGELOG*), 27 | licenses: ["Apache-2.0"], 28 | links: %{"GitHub" => "https://github.com/elixir-depot/depot"} 29 | ] 30 | end 31 | 32 | defp docs do 33 | [ 34 | groups_for_modules: [ 35 | Stat: [ 36 | ~r/^Depot\.Stat\./ 37 | ], 38 | Adapters: [ 39 | ~r/^Depot\.Adapter\./ 40 | ] 41 | ] 42 | ] 43 | end 44 | 45 | # Run "mix help compile.app" to learn about applications. 46 | def application do 47 | [ 48 | extra_applications: [:logger], 49 | mod: {Depot.Application, []} 50 | ] 51 | end 52 | 53 | # Run "mix help deps" to learn about dependencies. 54 | defp deps do 55 | [ 56 | {:ex_doc, "~> 0.21", only: :dev, runtime: false} 57 | ] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, 3 | "ex_doc": {:hex, :ex_doc, "0.25.2", "4f1cae793c4d132e06674b282f1d9ea3bf409bcca027ddb2fe177c4eed6a253f", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5b0c172e87ac27f14dfd152d52a145238ec71a95efbf29849550278c58a393d6"}, 4 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/depot/adapter/in_memory_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Depot.Adapter.InMemoryTest do 2 | use ExUnit.Case, async: true 3 | import Depot.AdapterTest 4 | doctest Depot.Adapter.InMemory 5 | 6 | adapter_test %{test: test} do 7 | filesystem = Depot.Adapter.InMemory.configure(name: test) 8 | start_supervised(filesystem) 9 | {:ok, filesystem: filesystem} 10 | end 11 | 12 | describe "write" do 13 | test "success", %{test: test} do 14 | {_, config} = filesystem = Depot.Adapter.InMemory.configure(name: test) 15 | 16 | start_supervised(filesystem) 17 | 18 | :ok = Depot.Adapter.InMemory.write(config, "test.txt", "Hello World", []) 19 | 20 | assert {:ok, {"Hello World", _meta}} = 21 | Agent.get(via(test), fn state -> 22 | state 23 | |> elem(0) 24 | |> Map.fetch!("/") 25 | |> elem(0) 26 | |> Map.fetch("test.txt") 27 | end) 28 | end 29 | 30 | test "folders are automatically created is missing", %{test: test} do 31 | {_, config} = filesystem = Depot.Adapter.InMemory.configure(name: test) 32 | 33 | start_supervised(filesystem) 34 | 35 | :ok = Depot.Adapter.InMemory.write(config, "folder/test.txt", "Hello World", []) 36 | 37 | assert {:ok, "Hello World"} = Depot.Adapter.InMemory.read(config, "folder/test.txt") 38 | end 39 | end 40 | 41 | describe "read" do 42 | test "success", %{test: test} do 43 | {_, config} = filesystem = Depot.Adapter.InMemory.configure(name: test) 44 | 45 | start_supervised(filesystem) 46 | 47 | :ok = 48 | Agent.update(via(test), fn _state -> 49 | {%{"/" => {%{"test.txt" => {"Hello World", %{}}}, %{}}}, %{}} 50 | end) 51 | 52 | assert {:ok, "Hello World"} = Depot.Adapter.InMemory.read(config, "test.txt") 53 | end 54 | 55 | test "stream success", %{test: test} do 56 | {_, config} = filesystem = Depot.Adapter.InMemory.configure(name: test) 57 | 58 | start_supervised(filesystem) 59 | 60 | :ok = 61 | Agent.update(via(test), fn _state -> 62 | {%{"/" => {%{"test.txt" => {"Hello World", %{}}}, %{}}}, %{}} 63 | end) 64 | 65 | assert {:ok, %Depot.Adapter.InMemory.AgentStream{} = stream} = 66 | Depot.Adapter.InMemory.read_stream(config, "test.txt", []) 67 | 68 | assert Enum.into(stream, <<>>) == "Hello World" 69 | end 70 | end 71 | 72 | describe "delete" do 73 | test "success", %{test: test} do 74 | {_, config} = filesystem = Depot.Adapter.InMemory.configure(name: test) 75 | 76 | start_supervised(filesystem) 77 | 78 | :ok = 79 | Agent.update(via(test), fn _state -> 80 | {%{"/" => {%{"test.txt" => {"Hello World", %{}}}, %{}}}, %{}} 81 | end) 82 | 83 | assert :ok = Depot.Adapter.InMemory.delete(config, "test.txt") 84 | 85 | assert :error = 86 | Agent.get(via(test), fn state -> 87 | state 88 | |> elem(0) 89 | |> Map.fetch!("/") 90 | |> elem(0) 91 | |> Map.fetch("test.txt") 92 | end) 93 | end 94 | 95 | test "successful even if no file to delete", %{test: test} do 96 | {_, config} = filesystem = Depot.Adapter.InMemory.configure(name: test) 97 | 98 | start_supervised(filesystem) 99 | 100 | assert :ok = Depot.Adapter.InMemory.delete(config, "test.txt") 101 | end 102 | end 103 | 104 | defp via(name) do 105 | Depot.Registry.via(Depot.Adapter.InMemory, name) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /test/depot/adapter/local_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Depot.Adapter.LocalTest do 2 | use ExUnit.Case, async: true 3 | use Bitwise, only_operators: true 4 | import Depot.AdapterTest 5 | doctest Depot.Adapter.Local 6 | 7 | @moduletag :tmp_dir 8 | 9 | def match_mode(input, match) do 10 | (input &&& 0o777) == match 11 | end 12 | 13 | adapter_test %{tmp_dir: prefix} do 14 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 15 | {:ok, filesystem: filesystem} 16 | end 17 | 18 | describe "write" do 19 | test "success", %{tmp_dir: prefix} do 20 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 21 | 22 | :ok = Depot.Adapter.Local.write(config, "test.txt", "Hello World", []) 23 | 24 | assert {:ok, "Hello World"} = File.read(Path.join(prefix, "test.txt")) 25 | end 26 | 27 | test "folders are automatically created is missing", %{tmp_dir: prefix} do 28 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 29 | 30 | :ok = Depot.Adapter.Local.write(config, "folder/test.txt", "Hello World", []) 31 | 32 | assert {:ok, "Hello World"} = File.read(Path.join(prefix, "folder/test.txt")) 33 | end 34 | 35 | test "stream options", %{tmp_dir: prefix} do 36 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 37 | 38 | assert {:ok, %File.Stream{line_or_bytes: :line, modes: [:raw, :read_ahead, :binary]}} = 39 | Depot.Adapter.Local.write_stream(config, "test.txt", []) 40 | 41 | assert {:ok, %File.Stream{line_or_bytes: 1_024, modes: [:raw, :read_ahead, :binary]}} = 42 | Depot.Adapter.Local.write_stream(config, "test.txt", chunk_size: 1_024) 43 | 44 | assert {:ok, %File.Stream{modes: [{:encoding, :utf8}, :binary]}} = 45 | Depot.Adapter.Local.write_stream(config, "test.txt", modes: [encoding: :utf8]) 46 | end 47 | 48 | test "stream success", %{tmp_dir: prefix} do 49 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 50 | 51 | assert {:ok, %File.Stream{} = stream} = 52 | Depot.Adapter.Local.write_stream(config, "test.txt", []) 53 | 54 | Enum.into(["Hello", " ", "World"], stream) 55 | 56 | assert {:ok, "Hello World"} = File.read(Path.join(prefix, "test.txt")) 57 | end 58 | 59 | test "default visibility", %{tmp_dir: prefix} do 60 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 61 | 62 | :ok = Depot.Adapter.Local.write(config, "public.txt", "Hello World", visibility: :public) 63 | :ok = Depot.Adapter.Local.write(config, "private.txt", "Hello World", visibility: :private) 64 | 65 | assert %{mode: mode} = Path.join(prefix, "public.txt") |> File.stat!() 66 | assert match_mode(mode, 0o644) 67 | 68 | assert %{mode: mode} = Path.join(prefix, "private.txt") |> File.stat!() 69 | assert match_mode(mode, 0o600) 70 | end 71 | 72 | test "folder visibility", %{tmp_dir: prefix} do 73 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 74 | 75 | :ok = 76 | Depot.Adapter.Local.write(config, "public/file.txt", "Hello World", visibility: :public) 77 | 78 | :ok = 79 | Depot.Adapter.Local.write(config, "private/file.txt", "Hello World", 80 | directory_visibility: :private 81 | ) 82 | 83 | assert %{mode: mode} = prefix |> File.stat!() 84 | assert match_mode(mode, 0o755) 85 | 86 | assert %{mode: mode} = Path.join(prefix, "public/") |> File.stat!() 87 | assert match_mode(mode, 0o755) 88 | 89 | assert %{mode: mode} = Path.join(prefix, "private/") |> File.stat!() 90 | assert match_mode(mode, 0o700) 91 | end 92 | end 93 | 94 | describe "read" do 95 | test "success", %{tmp_dir: prefix} do 96 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 97 | 98 | :ok = File.write(Path.join(prefix, "test.txt"), "Hello World") 99 | 100 | assert {:ok, "Hello World"} = Depot.Adapter.Local.read(config, "test.txt") 101 | end 102 | 103 | test "stream options", %{tmp_dir: prefix} do 104 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 105 | 106 | assert {:ok, %File.Stream{line_or_bytes: :line, modes: [:raw, :read_ahead, :binary]}} = 107 | Depot.Adapter.Local.read_stream(config, "test.txt", []) 108 | 109 | assert {:ok, %File.Stream{line_or_bytes: 1_024, modes: [:raw, :read_ahead, :binary]}} = 110 | Depot.Adapter.Local.read_stream(config, "test.txt", chunk_size: 1_024) 111 | 112 | assert {:ok, %File.Stream{modes: [{:encoding, :utf8}, :binary]}} = 113 | Depot.Adapter.Local.read_stream(config, "test.txt", modes: [encoding: :utf8]) 114 | end 115 | 116 | test "stream success", %{tmp_dir: prefix} do 117 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 118 | 119 | :ok = File.write(Path.join(prefix, "test.txt"), "Hello World") 120 | 121 | assert {:ok, %File.Stream{} = stream} = 122 | Depot.Adapter.Local.read_stream(config, "test.txt", []) 123 | 124 | assert Enum.into(stream, <<>>) == "Hello World" 125 | end 126 | end 127 | 128 | describe "delete" do 129 | test "success", %{tmp_dir: prefix} do 130 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 131 | 132 | :ok = File.write(Path.join(prefix, "test.txt"), "Hello World") 133 | 134 | assert :ok = Depot.Adapter.Local.delete(config, "test.txt") 135 | 136 | assert {:error, :enoent} = File.read(Path.join(prefix, "folder/test.txt")) 137 | end 138 | 139 | test "successful even if no file to delete", %{tmp_dir: prefix} do 140 | {_, config} = Depot.Adapter.Local.configure(prefix: prefix) 141 | 142 | assert :ok = Depot.Adapter.Local.delete(config, "test.txt") 143 | 144 | assert {:error, :enoent} = File.read(Path.join(prefix, "folder/test.txt")) 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /test/depot/relative_path_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Depot.RelativePathTest do 2 | use ExUnit.Case, async: true 3 | alias Depot.RelativePath 4 | 5 | test "relative?" do 6 | assert RelativePath.relative?("path/to/dir") 7 | refute RelativePath.relative?("/path/to/dir") 8 | refute RelativePath.relative?("C:/path/to/dir") 9 | refute RelativePath.relative?("//path/to/dir") 10 | end 11 | 12 | test "expand" do 13 | assert {:ok, "path/to/dir"} = RelativePath.expand("path/to/dir") 14 | assert {:ok, "to/dir"} = RelativePath.expand("path/../to/dir") 15 | assert {:error, :traversal} = RelativePath.expand("../path/to/dir") 16 | assert {:error, :traversal} = RelativePath.expand("path/../../path/to/dir") 17 | assert {:error, :traversal} = RelativePath.expand("path/../path/../../to/dir") 18 | assert {:error, :traversal} = RelativePath.expand("path/../path/to/../../../") 19 | end 20 | 21 | test "join_prefix" do 22 | assert "/path/to/dir" = RelativePath.join_prefix("/", "path/to/dir") 23 | assert "/path/to/dir/" = RelativePath.join_prefix("/", "path/to/dir/") 24 | assert "/prefix/path/to/dir" = RelativePath.join_prefix("/prefix", "path/to/dir") 25 | assert "/prefix/path/to/dir" = RelativePath.join_prefix("/prefix/", "path/to/dir") 26 | assert "C:/path/to/dir" = RelativePath.join_prefix("C:/", "path/to/dir") 27 | assert "C:/prefix/path/to/dir" = RelativePath.join_prefix("C:/prefix", "path/to/dir") 28 | assert "C:/prefix/path/to/dir" = RelativePath.join_prefix("C:/prefix/", "path/to/dir") 29 | assert "//prefix/path/to/dir" = RelativePath.join_prefix("//prefix/", "path/to/dir") 30 | end 31 | 32 | test "strip_prefix" do 33 | assert "path/to/dir" = RelativePath.strip_prefix("/", "/path/to/dir") 34 | assert "path/to/dir" = RelativePath.strip_prefix("/prefix", "/prefix/path/to/dir") 35 | assert "path/to/dir" = RelativePath.strip_prefix("/prefix/", "/prefix/path/to/dir") 36 | assert "path/to/dir" = RelativePath.strip_prefix("C:/", "C:/path/to/dir") 37 | assert "path/to/dir" = RelativePath.strip_prefix("C:/prefix", "C:/prefix/path/to/dir") 38 | assert "path/to/dir" = RelativePath.strip_prefix("C:/prefix/", "C:/prefix/path/to/dir") 39 | assert "path/to/dir" = RelativePath.strip_prefix("//prefix/", "//prefix/path/to/dir") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/depot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DepotTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmacrop assert_in_list(list, match) do 5 | quote do 6 | assert Enum.any?(unquote(list), &match?(unquote(match), &1)) 7 | end 8 | end 9 | 10 | describe "filesystem without own processes" do 11 | @describetag :tmp_dir 12 | 13 | test "user can write to filesystem", %{tmp_dir: prefix} do 14 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 15 | 16 | assert :ok = Depot.write(filesystem, "test.txt", "Hello World") 17 | end 18 | 19 | test "user can check if files exist on a filesystem", %{tmp_dir: prefix} do 20 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 21 | 22 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 23 | 24 | assert {:ok, :exists} = Depot.file_exists(filesystem, "test.txt") 25 | assert {:ok, :missing} = Depot.file_exists(filesystem, "not-test.txt") 26 | end 27 | 28 | test "user can read from filesystem", %{tmp_dir: prefix} do 29 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 30 | 31 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 32 | 33 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 34 | end 35 | 36 | test "user can delete from filesystem", %{tmp_dir: prefix} do 37 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 38 | 39 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 40 | :ok = Depot.delete(filesystem, "test.txt") 41 | 42 | assert {:error, _} = Depot.read(filesystem, "test.txt") 43 | end 44 | 45 | test "user can move files", %{tmp_dir: prefix} do 46 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 47 | 48 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 49 | :ok = Depot.move(filesystem, "test.txt", "not-test.txt") 50 | 51 | assert {:error, _} = Depot.read(filesystem, "test.txt") 52 | assert {:ok, "Hello World"} = Depot.read(filesystem, "not-test.txt") 53 | end 54 | 55 | test "user can copy files", %{tmp_dir: prefix} do 56 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 57 | 58 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 59 | :ok = Depot.copy(filesystem, "test.txt", "not-test.txt") 60 | 61 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 62 | assert {:ok, "Hello World"} = Depot.read(filesystem, "not-test.txt") 63 | end 64 | 65 | test "user can list files", %{tmp_dir: prefix} do 66 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 67 | 68 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 69 | :ok = Depot.write(filesystem, "test-1.txt", "Hello World") 70 | 71 | {:ok, list} = Depot.list_contents(filesystem, ".") 72 | 73 | assert length(list) == 2 74 | assert_in_list list, %Depot.Stat.File{name: "test.txt"} 75 | assert_in_list list, %Depot.Stat.File{name: "test-1.txt"} 76 | end 77 | end 78 | 79 | describe "module based filesystem without own processes" do 80 | @describetag :tmp_dir 81 | 82 | test "user can write to filesystem", %{tmp_dir: prefix} do 83 | defmodule Local.WriteTest do 84 | use Depot.Filesystem, 85 | adapter: Depot.Adapter.Local, 86 | prefix: prefix 87 | end 88 | 89 | assert :ok = Local.WriteTest.write("test.txt", "Hello World") 90 | end 91 | 92 | test "user can check if files exist on a filesystem", %{tmp_dir: prefix} do 93 | defmodule Local.FileExistsTest do 94 | use Depot.Filesystem, 95 | adapter: Depot.Adapter.Local, 96 | prefix: prefix 97 | end 98 | 99 | :ok = Local.FileExistsTest.write("test.txt", "Hello World") 100 | 101 | assert {:ok, :exists} = Local.FileExistsTest.file_exists("test.txt") 102 | assert {:ok, :missing} = Local.FileExistsTest.file_exists("not-test.txt") 103 | end 104 | 105 | test "user can read from filesystem", %{tmp_dir: prefix} do 106 | defmodule Local.ReadTest do 107 | use Depot.Filesystem, 108 | adapter: Depot.Adapter.Local, 109 | prefix: prefix 110 | end 111 | 112 | :ok = Local.ReadTest.write("test.txt", "Hello World") 113 | 114 | assert {:ok, "Hello World"} = Local.ReadTest.read("test.txt") 115 | end 116 | 117 | test "user can delete from filesystem", %{tmp_dir: prefix} do 118 | defmodule Local.DeleteTest do 119 | use Depot.Filesystem, 120 | adapter: Depot.Adapter.Local, 121 | prefix: prefix 122 | end 123 | 124 | :ok = Local.DeleteTest.write("test.txt", "Hello World") 125 | :ok = Local.DeleteTest.delete("test.txt") 126 | 127 | assert {:error, _} = Local.DeleteTest.read("test.txt") 128 | end 129 | 130 | test "user can move files", %{tmp_dir: prefix} do 131 | defmodule Local.MoveTest do 132 | use Depot.Filesystem, 133 | adapter: Depot.Adapter.Local, 134 | prefix: prefix 135 | end 136 | 137 | :ok = Local.MoveTest.write("test.txt", "Hello World") 138 | :ok = Local.MoveTest.move("test.txt", "not-test.txt") 139 | 140 | assert {:error, _} = Local.MoveTest.read("test.txt") 141 | assert {:ok, "Hello World"} = Local.MoveTest.read("not-test.txt") 142 | end 143 | 144 | test "user can copy files", %{tmp_dir: prefix} do 145 | defmodule Local.CopyTest do 146 | use Depot.Filesystem, 147 | adapter: Depot.Adapter.Local, 148 | prefix: prefix 149 | end 150 | 151 | :ok = Local.CopyTest.write("test.txt", "Hello World") 152 | :ok = Local.CopyTest.copy("test.txt", "not-test.txt") 153 | 154 | assert {:ok, "Hello World"} = Local.CopyTest.read("test.txt") 155 | assert {:ok, "Hello World"} = Local.CopyTest.read("not-test.txt") 156 | end 157 | 158 | test "user can list files", %{tmp_dir: prefix} do 159 | defmodule Local.ListContentsTest do 160 | use Depot.Filesystem, 161 | adapter: Depot.Adapter.Local, 162 | prefix: prefix 163 | end 164 | 165 | :ok = Local.ListContentsTest.write("test.txt", "Hello World") 166 | :ok = Local.ListContentsTest.write("test-1.txt", "Hello World") 167 | 168 | {:ok, list} = Local.ListContentsTest.list_contents(".") 169 | 170 | assert length(list) == 2 171 | assert_in_list list, %Depot.Stat.File{name: "test.txt"} 172 | assert_in_list list, %Depot.Stat.File{name: "test-1.txt"} 173 | end 174 | end 175 | 176 | describe "filesystem with own processes" do 177 | test "user can write to filesystem" do 178 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 179 | 180 | start_supervised(filesystem) 181 | 182 | assert :ok = Depot.write(filesystem, "test.txt", "Hello World") 183 | end 184 | 185 | test "user can check if files exist on a filesystem" do 186 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 187 | 188 | start_supervised(filesystem) 189 | 190 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 191 | 192 | assert {:ok, :exists} = Depot.file_exists(filesystem, "test.txt") 193 | assert {:ok, :missing} = Depot.file_exists(filesystem, "not-test.txt") 194 | end 195 | 196 | test "user can read from filesystem" do 197 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 198 | 199 | start_supervised(filesystem) 200 | 201 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 202 | 203 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 204 | end 205 | 206 | test "user can delete from filesystem" do 207 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 208 | 209 | start_supervised(filesystem) 210 | 211 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 212 | :ok = Depot.delete(filesystem, "test.txt") 213 | 214 | assert {:error, _} = Depot.read(filesystem, "test.txt") 215 | end 216 | 217 | test "user can move files" do 218 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 219 | 220 | start_supervised(filesystem) 221 | 222 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 223 | :ok = Depot.move(filesystem, "test.txt", "not-test.txt") 224 | 225 | assert {:error, _} = Depot.read(filesystem, "test.txt") 226 | assert {:ok, "Hello World"} = Depot.read(filesystem, "not-test.txt") 227 | end 228 | 229 | test "user can copy files" do 230 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 231 | 232 | start_supervised(filesystem) 233 | 234 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 235 | :ok = Depot.copy(filesystem, "test.txt", "not-test.txt") 236 | 237 | assert {:ok, "Hello World"} = Depot.read(filesystem, "test.txt") 238 | assert {:ok, "Hello World"} = Depot.read(filesystem, "not-test.txt") 239 | end 240 | 241 | test "user can list files" do 242 | filesystem = Depot.Adapter.InMemory.configure(name: InMemoryTest) 243 | 244 | start_supervised(filesystem) 245 | 246 | :ok = Depot.write(filesystem, "test.txt", "Hello World") 247 | :ok = Depot.write(filesystem, "test-1.txt", "Hello World") 248 | 249 | {:ok, list} = Depot.list_contents(filesystem, ".") 250 | 251 | assert length(list) == 2 252 | assert_in_list list, %Depot.Stat.File{name: "test.txt"} 253 | assert_in_list list, %Depot.Stat.File{name: "test-1.txt"} 254 | end 255 | end 256 | 257 | describe "module based filesystem with own processes" do 258 | test "user can write to filesystem" do 259 | defmodule InMemory.WriteTest do 260 | use Depot.Filesystem, 261 | adapter: Depot.Adapter.InMemory 262 | end 263 | 264 | start_supervised(InMemory.WriteTest) 265 | 266 | assert :ok = InMemory.WriteTest.write("test.txt", "Hello World") 267 | end 268 | 269 | test "user can check if files exist on a filesystem" do 270 | defmodule InMemory.FileExistsTest do 271 | use Depot.Filesystem, 272 | adapter: Depot.Adapter.InMemory 273 | end 274 | 275 | start_supervised(InMemory.FileExistsTest) 276 | 277 | :ok = InMemory.FileExistsTest.write("test.txt", "Hello World") 278 | 279 | assert {:ok, :exists} = InMemory.FileExistsTest.file_exists("test.txt") 280 | assert {:ok, :missing} = InMemory.FileExistsTest.file_exists("not-test.txt") 281 | end 282 | 283 | test "user can read from filesystem" do 284 | defmodule InMemory.ReadTest do 285 | use Depot.Filesystem, 286 | adapter: Depot.Adapter.InMemory 287 | end 288 | 289 | start_supervised(InMemory.ReadTest) 290 | 291 | :ok = InMemory.ReadTest.write("test.txt", "Hello World") 292 | 293 | assert {:ok, "Hello World"} = InMemory.ReadTest.read("test.txt") 294 | end 295 | 296 | test "user can delete from filesystem" do 297 | defmodule InMemory.DeleteTest do 298 | use Depot.Filesystem, 299 | adapter: Depot.Adapter.InMemory 300 | end 301 | 302 | start_supervised(InMemory.DeleteTest) 303 | 304 | :ok = InMemory.DeleteTest.write("test.txt", "Hello World") 305 | :ok = InMemory.DeleteTest.delete("test.txt") 306 | 307 | assert {:error, _} = InMemory.DeleteTest.read("test.txt") 308 | end 309 | 310 | test "user can move files" do 311 | defmodule InMemory.MoveTest do 312 | use Depot.Filesystem, 313 | adapter: Depot.Adapter.InMemory 314 | end 315 | 316 | start_supervised(InMemory.MoveTest) 317 | 318 | :ok = InMemory.MoveTest.write("test.txt", "Hello World") 319 | :ok = InMemory.MoveTest.move("test.txt", "not-test.txt") 320 | 321 | assert {:error, _} = InMemory.MoveTest.read("test.txt") 322 | assert {:ok, "Hello World"} = InMemory.MoveTest.read("not-test.txt") 323 | end 324 | 325 | test "user can copy files" do 326 | defmodule InMemory.CopyTest do 327 | use Depot.Filesystem, 328 | adapter: Depot.Adapter.InMemory 329 | end 330 | 331 | start_supervised(InMemory.CopyTest) 332 | 333 | :ok = InMemory.CopyTest.write("test.txt", "Hello World") 334 | :ok = InMemory.CopyTest.copy("test.txt", "not-test.txt") 335 | 336 | assert {:ok, "Hello World"} = InMemory.CopyTest.read("test.txt") 337 | assert {:ok, "Hello World"} = InMemory.CopyTest.read("not-test.txt") 338 | end 339 | 340 | test "user can list files" do 341 | defmodule InMemory.ListContentsTest do 342 | use Depot.Filesystem, 343 | adapter: Depot.Adapter.InMemory 344 | end 345 | 346 | start_supervised(InMemory.ListContentsTest) 347 | 348 | :ok = InMemory.ListContentsTest.write("test.txt", "Hello World") 349 | :ok = InMemory.ListContentsTest.write("test-1.txt", "Hello World") 350 | 351 | {:ok, list} = InMemory.ListContentsTest.list_contents(".") 352 | 353 | assert length(list) == 2 354 | assert_in_list list, %Depot.Stat.File{name: "test.txt"} 355 | assert_in_list list, %Depot.Stat.File{name: "test-1.txt"} 356 | end 357 | end 358 | 359 | describe "filesystem independant" do 360 | @describetag :tmp_dir 361 | 362 | setup %{tmp_dir: prefix} do 363 | filesystem = Depot.Adapter.Local.configure(prefix: prefix) 364 | {:ok, filesystem: filesystem} 365 | end 366 | 367 | test "reads configuration from :otp_app", context do 368 | configuration = [ 369 | adapter: Depot.Adapter.Local, 370 | prefix: "ziKK7t5LzV5XiJjYh30KxCLorRXqLwwEnZYJ" 371 | ] 372 | 373 | Application.put_env(:depot_test, DepotTest.AdhocFilesystem, configuration) 374 | 375 | defmodule AdhocFilesystem do 376 | use Depot.Filesystem, otp_app: :depot_test 377 | end 378 | 379 | {_module, module_config} = DepotTest.AdhocFilesystem.__filesystem__() 380 | 381 | assert module_config.prefix == "ziKK7t5LzV5XiJjYh30KxCLorRXqLwwEnZYJ" 382 | end 383 | 384 | test "directory traversals are detected and reported", %{filesystem: filesystem} do 385 | {:error, {:path, :traversal}} = Depot.write(filesystem, "../test.txt", "Hello World") 386 | {:error, {:path, :traversal}} = Depot.read(filesystem, "../test.txt") 387 | {:error, {:path, :traversal}} = Depot.delete(filesystem, "../test.txt") 388 | {:error, {:path, :traversal}} = Depot.list_contents(filesystem, "../test") 389 | end 390 | 391 | test "relative paths are required", %{filesystem: filesystem} do 392 | {:error, {:path, :absolute}} = Depot.write(filesystem, "/../test.txt", "Hello World") 393 | {:error, {:path, :absolute}} = Depot.read(filesystem, "/../test.txt") 394 | {:error, {:path, :absolute}} = Depot.delete(filesystem, "/../test.txt") 395 | {:error, {:path, :absolute}} = Depot.list_contents(filesystem, "/../test") 396 | end 397 | end 398 | 399 | describe "copying between different filesystems" do 400 | @describetag :tmp_dir 401 | 402 | setup %{tmp_dir: prefix} do 403 | prefix_a = Path.join(prefix, "a") 404 | prefix_b = Path.join(prefix, "b") 405 | 406 | {:ok, prefixes: [prefix_a, prefix_b]} 407 | end 408 | 409 | test "direct copy - same adapter", %{prefixes: [prefix_a, prefix_b]} do 410 | filesystem_a = Depot.Adapter.Local.configure(prefix: prefix_a) 411 | filesystem_b = Depot.Adapter.Local.configure(prefix: prefix_b) 412 | 413 | :ok = Depot.write(filesystem_a, "test.txt", "Hello World") 414 | 415 | assert :ok = 416 | Depot.copy_between_filesystem( 417 | {filesystem_a, "test.txt"}, 418 | {filesystem_b, "test.txt"} 419 | ) 420 | 421 | assert {:ok, :exists} = Depot.file_exists(filesystem_b, "test.txt") 422 | end 423 | 424 | test "indirect copy - same adapter" do 425 | filesystem_a = Depot.Adapter.InMemory.configure(name: InMemoryTest.A) 426 | filesystem_b = Depot.Adapter.InMemory.configure(name: InMemoryTest.B) 427 | 428 | filesystem_a |> Supervisor.child_spec(id: :a) |> start_supervised() 429 | filesystem_b |> Supervisor.child_spec(id: :b) |> start_supervised() 430 | 431 | :ok = Depot.write(filesystem_a, "test.txt", "Hello World") 432 | 433 | assert :ok = 434 | Depot.copy_between_filesystem( 435 | {filesystem_a, "test.txt"}, 436 | {filesystem_b, "test.txt"} 437 | ) 438 | 439 | assert {:ok, :exists} = Depot.file_exists(filesystem_b, "test.txt") 440 | end 441 | 442 | test "different adapter", %{prefixes: [prefix_a | _]} do 443 | filesystem_a = Depot.Adapter.Local.configure(prefix: prefix_a) 444 | filesystem_b = Depot.Adapter.InMemory.configure(name: InMemoryTest.B) 445 | 446 | start_supervised(filesystem_b) 447 | 448 | :ok = Depot.write(filesystem_a, "test.txt", "Hello World") 449 | 450 | assert :ok = 451 | Depot.copy_between_filesystem( 452 | {filesystem_a, "test.txt"}, 453 | {filesystem_b, "test.txt"} 454 | ) 455 | 456 | assert {:ok, :exists} = Depot.file_exists(filesystem_b, "test.txt") 457 | end 458 | end 459 | end 460 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------