Malachi.Console.Assets (malachi v0.22.7)

View Source

The console bundle as the endpoint serves it: a manifest built once, when the endpoint starts, from the files under the static directory.

The manifest is the whole serving surface. Malachi.Console.Static looks a request up in it by exact path and never turns a request into a filesystem path, so nothing outside the manifest can be served, whatever the request spells. Building it is where the rules about what counts as a servable file live:

  • a name starting with a dot (the directory's own .gitignore among them) is skipped, and so is everything below a directory whose name does;
  • a symbolic link is skipped, file or directory, so no link can reach outside the static root;
  • a file ending in .br or .gz whose uncompressed sibling exists is a precompressed variant of that sibling, not a file of its own; one without a sibling is an ordinary file;
  • every file and every variant is hashed with SHA-256, and the hash is its strong ETag. A hash of the content, not of size and modification time, is the same on every node of a cluster and across a redeploy of identical bytes.

The set is fixed at startup: a file added later answers 404 until the endpoint restarts, which is what a release does anyway. A file that cannot be read when the manifest is built (its mode, its owner, or removed in between) is left out with a warning; a precompressed variant that cannot be read is left out of its file. With no readable index.html the bundle is :absent and the endpoint answers

  1. Nothing in a bundle can stop the endpoint, let alone the node, from starting.

Each representation also records the identity of its file and the directories between it and the static root (unchanged?/1), so a file changed, removed or swapped for a symbolic link after startup, or one below a directory swapped for a link, is refused at request time instead of served. unchanged?/1 says what that check cannot see.

The manifest is handed to the plug as its options (plug: {Malachi.Console.Router, opts}), so no global state is involved. The price is memory: plug options are copied into every connection process, so each open connection holds its own copy of the manifest (large binaries excepted, which the VM shares), and the total grows with the number of files times the number of connections. It is bounded by :console_max_connections.

Summary

Types

One file: its content type, how browsers may cache it, and its representations, keyed by content coding ("identity", and "br" or "gzip" when a precompressed variant was shipped).

t()

One servable representation of a file: where its bytes are, its ETag and its size.

Functions

Builds the manifest for dir. A directory that does not exist, or one with no index.html, gives :absent, logged once as a warning.

The coding names a variant can carry, most preferred first.

The directory the bundle is read from: :console_static_dir, else priv/static/console.

Whether the file behind variant still looks like the one hashed at startup. Nothing below the static root may be a symbolic link, the file or any directory between it and the root, checked with lstat so no link is followed; and the file must have the same device, inode, size and modification time. A file or directory swapped for a link (even a link to the very file that was moved out of the root, which keeps its inode), a file rewritten with a different size or a newer modification time, and a file removed all answer false, and the caller refuses to serve it.

Types

entry()

@type entry() :: %{
  content_type: String.t(),
  cache_control: String.t(),
  variants: %{required(String.t()) => variant()}
}

One file: its content type, how browsers may cache it, and its representations, keyed by content coding ("identity", and "br" or "gzip" when a precompressed variant was shipped).

t()

@type t() :: %{files: %{required(String.t()) => entry()}, index: entry()} | :absent

variant()

@type variant() :: %{
  path: Path.t(),
  etag: String.t(),
  size: non_neg_integer(),
  identity:
    {non_neg_integer(), non_neg_integer(), non_neg_integer(), integer()},
  ancestors: [Path.t()]
}

One servable representation of a file: where its bytes are, its ETag and its size.

Functions

build(dir)

@spec build(Path.t()) :: t()

Builds the manifest for dir. A directory that does not exist, or one with no index.html, gives :absent, logged once as a warning.

codings()

@spec codings() :: [String.t()]

The coding names a variant can carry, most preferred first.

static_dir()

@spec static_dir() :: Path.t()

The directory the bundle is read from: :console_static_dir, else priv/static/console.

unchanged?(map)

@spec unchanged?(variant()) :: boolean()

Whether the file behind variant still looks like the one hashed at startup. Nothing below the static root may be a symbolic link, the file or any directory between it and the root, checked with lstat so no link is followed; and the file must have the same device, inode, size and modification time. A file or directory swapped for a link (even a link to the very file that was moved out of the root, which keeps its inode), a file rewritten with a different size or a newer modification time, and a file removed all answer false, and the caller refuses to serve it.

This is a check made before the send, not a guarantee about it. It cannot see a rewrite in place that keeps the size and keeps or restores the modification time (within the same second, or with touch -r), nor a swap that happens between this check and the moment the file is opened to be sent.