1 Commits
Author SHA1 Message Date
sneak adfb6ab50a Detect a fetched image's format from its bytes once (closes #319)
check / check (push) Canceled after 0s
magic.DetectFormat returns an imagerequest.Format. ValidateMagicBytes
returns the format it checked against the upstream Content-Type. The
cache stores that format in the source's row, so a variant made from a
cached source uses it too. The proxy hands it to Process, which still
refuses input libvips would load with another loader, such as an SVG
passing as AVIF. Format.MIMEType declares each MIME string once, and the
fetcher, the cache's variant read and the Accept matching use it.
magic.MIMEType, magic.ImageFormat, MIMEToImageFormat, magic's SVG
detection, detectFormat, formatFromString, FormatToMIME and
Result.InputFormat are gone.

Model: opus-5-5
2026-10-26 19:46:01 +00:00
22 changed files with 916 additions and 1558 deletions
+51 -52
View File
@@ -1,76 +1,75 @@
# Repository Rules # Repository Rules
Last Updated 2026-10-27 Last Updated 2026-01-08
These rules MUST be followed at all times. This is very important. These rules MUST be followed at all times, it is very important.
- Never use `git add -A`. Stage specific changes for a deliberate commit. A - Never use `git add -A` - add specific changes to a deliberate commit. A commit
commit holds one change. After each change, make a commit with a good one-line should contain one change. After each change, make a commit with a good
summary. one-line summary.
- NEVER modify the linter config without asking first. - NEVER modify the linter config without asking first.
- NEVER modify a test to exclude special cases or otherwise make it pass without - NEVER modify tests to exclude special cases or otherwise get them to pass
asking first. In almost all cases, change the code, NOT the test. You need without asking first. In almost all cases, the code should be changed, NOT the
explicit user approval to modify an existing test. If you think a test needs tests. If you think the test needs to be changed, make your case for that and
to change, make your case and ask for permission to proceed. Then stop. A bug ask for permission to proceed, then stop. You need explicit user approval to
fix may change an existing test without asking only when that test pins modify existing tests. A bug fix may change an existing test without asking
exactly the behavior the filed bug calls wrong. The change must then move the when that test pins exactly the behavior the filed bug calls wrong, and the
test to the fixed behavior and nothing else. Any other change to an existing change moves it to the fixed behavior and nothing else; any other change to an
test needs explicit user approval. Writing NEW tests needs no approval. existing test still needs explicit user approval. (You do not need user
approval for writing NEW tests.)
- When linting, assume the linter config is CORRECT. Treat each item the linter - When linting, assume the linter config is CORRECT, and that each item output
reports as something that legitimately needs fixing in the code. by the linter is something that legitimately needs fixing in the code.
- When running tests, use `make test`. - When running tests, use `make test`.
- Before committing, run `make check`. It runs `make test`, `make lint` and - Before commits, run `make check`. This runs `make lint` and `make test` and
`make fmt-check`. Any issue it finds MUST be resolved before committing, `make check-fmt`. Any issues discovered MUST be resolved before committing
unless you are explicitly told otherwise. unless explicitly told otherwise.
- When fixing a bug, write a failing test for the bug FIRST. Add appropriate - When fixing a bug, write a failing test for the bug FIRST. Add appropriate
logging to the test, to make sure the test is written correctly. Commit that. logging to the test to ensure it is written correctly. Commit that. Then go
Then work on the fix until the test passes, without changing the test further. about fixing the bug until the test passes (without modifying the test
Then commit the fix. further). Then commit that.
- When adding a new feature, do the same: write a test first (test-driven - When adding a new feature, do the same - implement a test first (TDD). It
development, TDD). The test does not need to be very complex. Commit the test, doesn't have to be super complex. Commit the test, then commit the feature.
then commit the feature.
- When adding a new feature, use a feature branch. The feature branch reaches - When adding a new feature, use a feature branch. When the feature is
`next` through a PR that passes review. It is merged only when the feature is completely finished and the code is up to standards (passes `make check`) then
completely finished and the code is up to standards. The code is up to and only then can the feature branch be merged into `main` and the branch
standards when it passes `make check`. The branch is deleted after the merge. deleted.
Only the user merges `next` into `main`.
- Write `godoc` comments for all exported types and functions as you go along. - Write godoc documentation comments for all exported types and functions as you
go along.
- ALWAYS be consistent in naming. If you name something one way in one place, - ALWAYS be consistent in naming. If you name something one thing in one place,
use the EXACT SAME name in every other place. name it the EXACT SAME THING in another place.
- Be descriptive and specific in naming. `al` is bad; `SourceHostAllowlist` is - Be descriptive and specific in naming. `wl` is bad; `SourceHostWhitelist` is
good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good. good. `ConnsPerHost` is bad; `MaxConnectionsPerHost` is good.
- This is not prototype or teaching code. It is designed for production. Any - This is not prototype or teaching code - this is designed for production. Any
security issue (such as denial of service) or other web vulnerability is a P1 security issues (such as denial of service) or other web vulnerabilities are
bug. It must be added at the top of `TODO.md`. P1 bugs and must be added to TODO.md at the top.
- Do not stub an implementation unless you are specifically instructed to. This - As this is production code, no stubbing of implementations unless specifically
is production code, and we need working implementations. instructed. We need working implementations.
- NEVER silently fall back to a different setting when a user's parameter - NEVER silently fall back to a different setting when a user's parameter
explicitly specifies a value. For example, if a user requests `format=webp` explicitly specifies a value. If a user requests format=webp and WebP encoding
and WebP encoding is not supported, return an error. Do NOT silently output is not supported, return an error - do NOT silently output PNG instead. If a
PNG instead. If a user specifies `fit=invalid` and that fit mode does not user specifies fit=invalid and that fit mode doesn't exist, return an error -
exist, return an error. Do NOT silently default to `cover`. A silent fallback do NOT silently default to "cover". Silent fallbacks violate the principle of
violates the principle of least surprise and masks bugs. Defaults are least surprise and mask bugs. The only acceptable defaults are for OMITTED
acceptable only for OMITTED parameters, never for INVALID explicit values. parameters, never for INVALID explicit values.
- Avoid vendoring dependencies unless specifically instructed to. NEVER commit - Avoid vendoring deps unless specifically instructed to. NEVER commit the
the `vendor/` directory. NEVER commit compiled binaries. If such a directory vendor directory, NEVER commit compiled binaries. If these directories or
or file exists and `.gitignore` does not list it, add it to `.gitignore`. files exist, add them to .gitignore (and commit the .gitignore) if they are
Commit that `.gitignore` change. Keep the entire git repository, history not already in there. Keep the entire git repository (with history) small -
included, under 20 MiB. Go over 20 MiB only when you specifically must commit under 20MiB, unless you specifically must commit larger files (e.g. test
a large file, such as a media file used as a test fixture. Only OUR source fixture example media files). Only OUR source code and immediately supporting
code and its immediately supporting files, such as test examples, go into the files (such as test examples) goes into the repo/history.
repository and its history.
+80 -183
View File
@@ -133,7 +133,7 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
filesystem's free space and what the cache holds, and at least 500 MiB, so filesystem's free space and what the cache holds, and at least 500 MiB, so
the cache can grow to three quarters or more of the space they all have the cache can grow to three quarters or more of the space they all have
left, and to all of it when that is 500 MiB or less. left, and to all of it when that is 500 MiB or less.
- the rest are in the list under Configuration below - the rest are in the table under Configuration below
- **Memory:** pixa can use up to `max_concurrent_processing` times 4.2 GiB for - **Memory:** pixa can use up to `max_concurrent_processing` times 4.2 GiB for
the images it processes at once, plus `upstream_connections` times the images it processes at once, plus `upstream_connections` times
`upstream_max_response_size` for the source images it has fetched that wait `upstream_max_response_size` for the source images it has fetched that wait
@@ -178,100 +178,47 @@ allowlisted source hosts for open access.
## Design ## Design
### Concepts
These terms are the names the code uses:
- **Source**: the image at one upstream URL, named by its host, path and query.
- **Source key**: the SHA-256 of a source's host, path and query, which names
the source's file and its row in the database.
- **Transform**: the output size, format, quality and fit an image request asks
for; with the format `auto`, pixa chooses the output format from the `Accept`
header.
- **Image request**: a source and a transform, which each image route reads from
its URL.
- **Variant**: the image pixa serves for an image request, made from its source
with its transform.
- **Variant key**: the SHA-256 of a variant's source, size, format, quality and
fit, which names the variant's file and its row in the database, and gives its
`ETag`.
- **Signed URL**: a `/v1/image/` URL whose `sig` signs its image request and its
expiry `exp` with `signing_key`.
- **Encrypted URL**: a `/v1/e/` URL whose token holds its image request and any
expiry, encrypted with a key derived from `signing_key`.
- **Login session**: an encrypted cookie that keeps a browser logged in to the
generator page for 30 days, or until a logout replaces the random value pixa
keeps in the `logout` table (see Encrypted URLs).
- **Negative cache**: the source URLs pixa does not fetch again for 5 minutes,
after one of these failed fetches:
- the upstream answered with an error status
- the body's first bytes were not those of the format its `Content-Type`
names
- **Image cache**: the part of pixa that keeps the sources, the variants and the
negative cache (see Storage).
- **Disk cache limit** (`cache_max_bytes`): the size in bytes that the cached
sources and variants may reach together.
- **Image proxy**: the part of pixa that serves a variant from the image cache
when it can, and otherwise makes it from the source.
### Storage ### Storage
- **Sources**: `<state_dir>/cache/sources/<ab>/<cd>/<source key>`, one file for - **Source images**:
each source URL `<state_dir>/cache/sources/<ab>/<cd>/<sha256 of host, path and query>`, one
file for each source URL
- **Database**: `<state_dir>/state.sqlite3` (SQLite) - **Database**: `<state_dir>/state.sqlite3` (SQLite)
- **Variants**: `<state_dir>/cache/variants/<ab>/<cd>/<variant key>` - **Transformed images**:
`<state_dir>/cache/variants/<ab>/<cd>/<sha256 of host, path, query, size, format, quality and fit>`
`<ab>` and `<cd>` are the first and second pairs of characters of the file's `<ab>` and `<cd>` are the first and second pairs of characters of the file's
name. name.
The image cache keeps these tables in the database: Each source image and each transformed image has one row in the database,
holding its size and when it was last used. A source image's row also holds its
format, which pixa reads from the image's first bytes when it fetches it. Two
source URLs that serve the same bytes are stored twice, once under each URL. A
source image is stored by writing its file and then its row; when the row cannot
be written, the file is removed again.
- `source_content`: a row for each source, by source key, with its size and when Source and transformed image files are written under a temporary name, synced to
it was last used disk, and only then renamed into place, so a crash leaves no file under its own
- `variant_content`: a row for each variant, by variant key (column name with part of its data. When pixa starts, and every five minutes after, it
`cache_key`), with its size and when it was last used removes each cached source or transformed image whose file is empty, or not the
- `cache_usage`: the total size of the cached sources and variants, which size the database records for it, with a warning naming the file; the next
eviction reads request for it fetches the source or converts the image again. Looking for those
- `negative_cache`: a row for each source URL in the negative cache, with when files reads the database once for every cached file, so pixa skips each
it expires five-minute look that would start less than ten times as long after the last
look ended as that look took. After a look that took one minute, the next one
pixa reads a cached source image's format from its file's first bytes. When they starts 10 to 15 minutes after that look ended.
match none of the source formats, pixa fetches the source again. Two source URLs
that serve the same bytes are stored twice, once under each URL. A source image
is stored by writing its file and then its row; when the row cannot be written,
the file is removed again.
Each source and variant file is written under a temporary name and synced to
disk before it is renamed into place. So a crash never leaves part of a file
under its own name. When pixa starts, and every five minutes after, it removes
each source or variant whose file is empty, or not the size its row records. It
logs a warning naming the file. A later request for that image fetches the
source or makes the variant again. Looking for those files reads the database
once for every cached file, so pixa skips each five-minute look that would start
less than ten times as long after the last look ended as that look took. After a
look that took one minute, the next one starts 10 to 15 minutes after that look
ended.
Each look also drops the rows of variants whose files are missing. When pixa
cannot read a variant file's size for another reason, it keeps the variant's
row. It logs a warning naming the variant key and the error.
During a look, pixa skips a directory it cannot list and a file whose size it
cannot read. It logs a warning naming the path and the error, and goes on with
the rest of the look. A file renamed or removed after pixa listed its directory
gets no warning.
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
Test). Toward it, a cache hit opens the variant's file and nothing else, and Test). Toward it, a cache hit opens the transformed image's file and nothing
makes no database call. The content type comes from the file's first bytes, else, and makes no database call. The content type comes from the file's first
which are those of the format pixa wrote it in; a file whose first bytes are not bytes, which are those of the format pixa wrote it in; a file whose first bytes
those of a format pixa writes is not served, and the image is converted again. A are not those of a format pixa writes is not served, and the image is converted
hit hands the time it was served to a background writer and does not wait for again. A hit hands the time it was served to a background writer and does not
the writer. Eviction orders by that time. The hit is counted in memory. A miss wait for the writer. Eviction orders by that time. The hit is counted in memory.
that uses a cached source image also hands that writer the time it used the A miss that uses a cached source image also hands that writer the time it used
image. Once a second that writer writes the waiting times in one transaction. Up the image. Once a second that writer writes the waiting times in one
to 10,000 access times of variants, and as many of sources, can wait to be transaction. While 10,000 times of transformed images, or 10,000 of source
written. A time handed over beyond that is dropped. Its image then only looks images, wait to be written, a further one is dropped, and that image only looks
less recently used to eviction. `script/bench` measures how many hits pixa less recently used to eviction. `script/bench` measures how many hits pixa
serves per second (see Load Test). serves per second (see Load Test).
@@ -319,8 +266,6 @@ path under `/v1/` answers 200, in maintenance mode too.
- 403 when the upstream host, or a host it redirects to, has an address in a - 403 when the upstream host, or a host it redirects to, has an address in a
blocked network (see `blocked_networks`). blocked network (see `blocked_networks`).
- 429 over `image_requests_per_minute` (below). - 429 over `image_requests_per_minute` (below).
- 499, with no body, when the client goes away before its image is ready,
unless its request is the one making the image (below).
- 415 when the upstream answers with a `Content-Type` that is not one of the - 415 when the upstream answers with a `Content-Type` that is not one of the
source formats (below). source formats (below).
- 415 when the upstream sends a body whose first bytes are not those of the - 415 when the upstream sends a body whose first bytes are not those of the
@@ -352,7 +297,6 @@ path under `/v1/` answers 200, in maintenance mode too.
- 403 - 403
- 415 - 415
- 429 - 429
- 499, with no body
- 502 - 502
- 503 - 503
- 504 - 504
@@ -734,85 +678,54 @@ refused with 400.
Every setting can be given as an environment variable, in a YAML config file Every setting can be given as an environment variable, in a YAML config file
(`--config`), or both. A variable present in the environment wins over the file, (`--config`), or both. A variable present in the environment wins over the file,
even when it is empty, and the file wins over the built-in default. The one even when it is empty, and the file wins over the built-in default. The one
exception is a variable set in the file's `env:` section. pixa sets that exception is a variable named in the file's `env:` section: it is set while the
variable while loading the file, so the variable wins over the environment pixa file loads, so it overrides both the environment the process was started with
started with. It also wins over the file's key for the same setting. A and the file's own key. A variable's value is parsed as the same text in the
variable's value is parsed as the same text in the file would be. A setting that file would be. The three lists take comma-separated entries, with the spaces
holds a list takes comma-separated entries, with the spaces around each trimmed. around each trimmed; an empty variable is an empty list. A value that does not
An empty variable is an empty list. A value that does not parse or is invalid parse or is invalid aborts startup, naming the variable. A variable whose name
aborts startup, naming the variable. pixa refuses to start when a variable starts with `PIXA_` but is not in the table below, such as a misspelled one or
starts with `PIXA_` but is not in the list below, such as a misspelled one or `PIXA_PORT`, aborts startup naming it, as an unknown config key does. The one
`PIXA_PORT`. The error names the variable, as it names an unknown config key. other accepted name is `PIXA_CONFIG_PATH`, the config file's path (like
pixa also accepts `PIXA_CONFIG_PATH`, which names the config file, like `--config`). The variables set by the file's `env:` section are checked the same
`--config`. The variables set by the file's `env:` section are checked the same
way. way.
pixa reads at most one config file. It reads the file given with `--config` (or pixa reads at most one config file: the one given with `--config` (or `-c`),
`-c`), or else the one `PIXA_CONFIG_PATH` names. pixa refuses to start when that otherwise the one `PIXA_CONFIG_PATH` names, otherwise the first of these that
file does not exist, cannot be read or does not parse. Without `--config` or pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
`PIXA_CONFIG_PATH`, pixa looks in `/etc/pixa/`, then in `~/.config/pixa/`, then `~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml` and
in the working directory. In each directory it looks for `config.yml`, then `config.yaml` in the working directory. A named file that does not exist, cannot
`config.yaml`. It reads the first file it finds. It skips a missing file without be read or does not parse aborts startup. Of the files pixa looks for on its
a message. pixa refuses to start when a file it finds cannot be read or does not own, only one that does not exist is passed over, without a message. One that
parse, and the error names the file. It also refuses to start when it may not pixa cannot read or parse aborts startup, naming the file. So does one in a
enter one of these directories, whether or not the file is there, since it directory pixa may not enter, whether or not it is there, since pixa cannot
cannot tell. With no file, pixa uses the environment and the defaults. tell. With no file, pixa uses the environment and the defaults.
Each item below is one setting, named by its config key: | Variable | Config key | Meaning |
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
- `signing_key`: variable `PIXA_SIGNING_KEY`, required. The secret for signed | `PIXA_SIGNING_KEY` | `signing_key` | Required: secret for signed and encrypted URLs and login, 32+ characters |
URLs, encrypted URLs and login. It must be 32 characters or longer. | `PORT` | `port` | Port to listen on; default `8080` |
- `port`: variable `PORT`, default `8080`. The port to listen on. | `PIXA_STATE_DIR` | `state_dir` | Directory for the database and the disk cache; default `/var/lib/pixa` |
- `state_dir`: variable `PIXA_STATE_DIR`, default `/var/lib/pixa`. The directory | `PIXA_DB_URL` | `db_url` | SQLite database URL; default `state.sqlite3` in the state directory |
for the database and the disk cache. | `PIXA_CACHE_MAX_BYTES` | `cache_max_bytes` | Disk cache limit in bytes; `0` disables it; default 75% of (free + cached) |
- `db_url`: variable `PIXA_DB_URL`, default `state.sqlite3` in the state | `PIXA_ALLOWLIST_HOSTS` | `allowlist_hosts` | Upstream hosts served without a signature |
directory. The URL of the SQLite database. | `PIXA_REFERER_BLOCKLIST` | `referer_blocklist` | Hosts whose pages the image routes refuse with 403, by `Referer` |
- `cache_max_bytes`: variable `PIXA_CACHE_MAX_BYTES`, default 75% of the sum of | `PIXA_BLOCKED_NETWORKS` | `blocked_networks` | CIDR ranges never fetched from, on top of the built-in ones |
the free space and what the cache holds. The disk cache limit in bytes. `0` | `PIXA_TRUSTED_PROXIES` | `trusted_proxies` | CIDR ranges of proxies whose `X-Forwarded-For` is believed; default RFC 1918 |
turns the disk cache off. | `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
- `allowlist_hosts`: variable `PIXA_ALLOWLIST_HOSTS`, default none. The upstream | `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
hosts whose images pixa serves without a signature. | `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
- `referer_blocklist`: variable `PIXA_REFERER_BLOCKLIST`, default none. The | `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default half the CPUs, at least 1 |
image routes refuse a request with 403 when its `Referer` header names one of | `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
these hosts. | `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
- `blocked_networks`: variable `PIXA_BLOCKED_NETWORKS`, default none. The CIDR | `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
ranges pixa never fetches from, on top of the built-in ones. | `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
- `trusted_proxies`: variable `PIXA_TRUSTED_PROXIES`, default the RFC 1918 | `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` |
ranges. The CIDR ranges of the proxies whose `X-Forwarded-For` header pixa | `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
believes. | `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
- `allow_http`: variable `PIXA_ALLOW_HTTP`, default `false`. Allows plain-HTTP | `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
upstream hosts. It is for testing only. | `PIXA_DEBUG` | `debug` | Debug logging and plain-HTTP local development; default `false` |
- `upstream_connections_per_host`: variable | `PIXA_MAINTENANCE_MODE` | `maintenance_mode` | Answer image requests with 503; the health check stays 200; default `false` |
`PIXA_UPSTREAM_CONNECTIONS_PER_HOST`, default `20`. The most connections to
one upstream host at once.
- `upstream_connections`: variable `PIXA_UPSTREAM_CONNECTIONS`, default `64`.
The most connections to all upstream hosts together at once.
- `max_concurrent_processing`: variable `PIXA_MAX_CONCURRENT_PROCESSING`,
default half the CPUs, at least 1. The most images processed at once.
- `image_requests_per_minute`: variable `PIXA_IMAGE_REQUESTS_PER_MINUTE`,
default `600`. How many image requests one client may send per minute. `0`
turns the limit off.
- `upstream_fetch_timeout`: variable `PIXA_UPSTREAM_FETCH_TIMEOUT`, default
`30s`. The time allowed for one fetch from an upstream host.
- `upstream_max_response_size`: variable `PIXA_UPSTREAM_MAX_RESPONSE_SIZE`,
default 50 MiB. The largest upstream response accepted, in bytes.
- `downstream_timeout`: variable `PIXA_DOWNSTREAM_TIMEOUT`, default `60s`. The
time allowed for answering one client request.
- `access_control_allow_origin`: variable `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN`,
default `*`. The CORS origin allowed to read image responses. It is `*` or one
origin.
- `metrics.username`: variable `PIXA_METRICS_USERNAME`, default empty. The
username for `/metrics`. pixa serves `/metrics` only when the username and the
password are both set.
- `metrics.password`: variable `PIXA_METRICS_PASSWORD`, default empty. The
password for `/metrics`. Set it together with the username.
- `sentry_dsn`: variable `PIXA_SENTRY_DSN`, default empty. The Sentry DSN for
error reporting. An empty DSN turns error reporting off.
- `debug`: variable `PIXA_DEBUG`, default `false`. Turns on debug logging and
plain-HTTP local development.
- `maintenance_mode`: variable `PIXA_MAINTENANCE_MODE`, default `false`. While
`true`, pixa answers image requests with 503. The health check still
answers 200.
Key settings in more detail: Key settings in more detail:
@@ -936,25 +849,9 @@ See `configs/config.example.yml` for all options with defaults.
### Architecture ### Architecture
- **Dependency injection**: Uber fx. `internal/app` puts every part of pixad in - **Dependency injection**: Uber fx. `internal/app` builds every part of pixad
one fx graph. `internal/providers` builds the image cache, the image proxy and from the config in one fx graph. Each setting's default is declared once, in
the other parts that serve images and logins. Each setting's default is `internal/config`.
declared once, in `internal/config`.
- **Packages**: these packages under `internal/` work with the concepts above:
- `handlers`: the routes, which read the image request from the URL, check
the signed or encrypted URL, and make the login session
- `imagerequest`: the image request, with the source key and the variant key
- `imageproxy`: the image proxy
- `imgcache`: the image cache, which keeps its sources in one `FileStore`
and its variants in another
- `httpfetcher`: fetching sources from upstream hosts
- `magic`: reading an image's format from its first bytes
- `imageprocessor`: making a variant from a source with libvips
- `signature`: checking the `sig` of a signed URL
- `encurl`: making and reading the tokens of encrypted URLs
- `seal`: the keys derived from `signing_key`, and the encryption of those
tokens
- `session`: the login session
- **HTTP router**: go-chi - **HTTP router**: go-chi
- **Image processing**: govips (CGO wrapper for libvips) - **Image processing**: govips (CGO wrapper for libvips)
- **Database**: SQLite via modernc.org/sqlite - **Database**: SQLite via modernc.org/sqlite
+3 -65
View File
@@ -30,66 +30,6 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-27 the reconciliation pass skips a directory it cannot list and a file
whose info it cannot read, and goes on (closes #343). It stopped before its
later checks at a directory it could not list, or at a source or variant file
whose info it could not read.
- It logs the warning "skipped unreadable entry in cache directory", with
the path and the error.
- A file renamed or removed after its directory was listed gets no warning.
- `reconcileFiles` reads each file's info once, before the temp-file check,
and hands it to `sweepStaleTempFile`, `reconcileVariantFile` or
`reconcileSourceFile`.
- A cancelled context still stops the walk with its error.
- 2026-10-26 the reconciliation pass drops a variant's accounting row only when
its file is missing (closes #340). Any other error reading the file's size
keeps the row. It dropped the row on any error and logged the file as missing.
- The kept row gets the warning "kept accounting row for unreadable variant
file", with the variant key and the error.
- The pass checks for `ErrNotFound`, which `FileStore.Size` returns for a
missing file.
- 2026-10-26 on both image routes, a request is answered 499 and logged at info
level when its client goes away before its image is ready, unless it is the
request making the image (closes #339). It was answered 500 and logged at
error level, as pixa's own failure.
- The line is "client went away before the image was ready". It carries the
`request_id`, the host and the path.
- The 499 has no body, since nobody reads it.
- `serveImage` answers it before calling `respondImageError`, which cannot
see the request's context.
- An error wrapping `context.Canceled` while the request's context is live
is still answered 500 and logged at error level.
- 2026-10-26 `README.md`'s Design section starts with a Concepts list (closes
#321). Each concept gets one sentence, under the name the code gives it.
- Storage calls the transformed images variants, in shorter sentences.
- Storage's paths end in the source key and the variant key.
- Storage lists the image cache's tables in the database.
- Architecture names `internal/providers`, and lists the packages that work
with the concepts.
- 2026-10-26 one table in `internal/config` lists every setting once (closes
#320). `Config.settings` gives each setting's key, its environment variable
and the function that loads it.
- Most rows load through `field`, whose arguments give the setting's type,
its default and its check.
- `db_url` and `cache_max_bytes` have their own load functions. The default
of `db_url` comes from `state_dir`, and `cache_max_bytes` records whether
it is set.
- Loading, the unknown-key check, the unknown-variable check and the names
in error messages all read the table.
- `allowlist_hosts` loads as `referer_blocklist` does, through
`parseHostList`.
- The metrics username and password are checked together once the table has
loaded.
- When a config holds more than one invalid value, the error names the one
that comes first in the table.
- `README.md` lists the settings one item each, where it had a table. A test
checks that this list and `configs/config.example.yml` name exactly the
keys of the table.
- The key constants moved to the tests, their only users.
- Removed:
- `isKnownConfigKey`, `envVarNames`, `strictLoader` and `validate`
- `validateAllowlistHostsValue` and `getStringSlice`, with the three
tests of `getStringSlice`
- 2026-10-26 a fetched image's format is detected from its bytes once, as an - 2026-10-26 a fetched image's format is detected from its bytes once, as an
`imagerequest.Format` (closes #319). The upstream `Content-Type` check in the `imagerequest.Format` (closes #319). The upstream `Content-Type` check in the
fetcher stays as the filter before downloading. fetcher stays as the filter before downloading.
@@ -99,13 +39,11 @@ P2: security: per-origin rate limiting
- `Format.MIMEType` gives each format's MIME type. The fetcher, the mock - `Format.MIMEType` gives each format's MIME type. The fetcher, the mock
fetcher, the cache's read of a variant's type and the handler's `Accept` fetcher, the cache's read of a variant's type and the handler's `Accept`
matching use it. matching use it.
- `Cache.Source` returns a source's format, read from its file's first bytes - The cache stores a source's format in its `source_content` row, and
as `Cache.Variant` reads a variant's. A file in no source format is an `Cache.Source` returns it. `Cache.PutSource` takes it.
error, and the proxy fetches the source again.
- The proxy hands the format to `ImageProcessor.Process`. The processor - The proxy hands the format to `ImageProcessor.Process`. The processor
refuses input that libvips would load as another format, as before, with refuses input that libvips would load as another format, as before, with
`ErrUnsupportedInputFormat`. It refuses a format that is not one of the `ErrUnsupportedInputFormat`.
six source formats the same way.
- `internal/magic` no longer detects SVG, which pixa does not read. SVG data - `internal/magic` no longer detects SVG, which pixa does not read. SVG data
is `ErrUnknownFormat`. is `ErrUnknownFormat`.
- Removed: - Removed:
+586 -345
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -1,12 +1,97 @@
package config package config
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/smartconfig" "git.eeqj.de/sneak/smartconfig"
) )
// writeTestConfig writes yamlContent to a temp config file and returns
// the file path.
func writeTestConfig(t *testing.T, yamlContent string) string {
t.Helper()
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yml")
err := os.WriteFile(configPath, []byte(yamlContent), 0o600)
if err != nil {
t.Fatalf("failed to write test config: %v", err)
}
return configPath
}
// checkAllowlistHosts loads the config at configPath and asserts that
// getStringSlice returns the three expected hosts.
func checkAllowlistHosts(t *testing.T, configPath string) {
t.Helper()
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc)
if len(hosts) != 3 {
t.Errorf("expected 3 hosts, got %d: %v", len(hosts), hosts)
}
expected := []string{"static.sneak.cloud", "sneak.berlin", testHostS3}
for i, want := range expected {
if i >= len(hosts) {
t.Errorf("missing host at index %d: want %q", i, want)
continue
}
if hosts[i] != want {
t.Errorf("host[%d] = %q, want %q", i, hosts[i], want)
}
}
}
func TestGetStringSlice_YAMLList(t *testing.T) {
t.Parallel()
yamlContent := `
allowlist_hosts:
- static.sneak.cloud
- sneak.berlin
- s3.sneak.cloud
`
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
}
func TestGetStringSlice_CommaSeparated(t *testing.T) {
t.Parallel()
// Backwards compatibility with comma-separated string values.
yamlContent := `allowlist_hosts: "static.sneak.cloud, sneak.berlin, s3.sneak.cloud"`
checkAllowlistHosts(t, writeTestConfig(t, yamlContent))
}
func TestGetStringSlice_Empty(t *testing.T) {
t.Parallel()
configPath := writeTestConfig(t, `port: 8080`)
sc, err := loadTestConfig(configPath)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
hosts := getStringSlice(sc)
if len(hosts) != 0 {
t.Errorf("expected nil or empty slice, got %v", hosts)
}
}
// TestExampleConfigAllowlistsNoHost loads configs/config.example.yml // TestExampleConfigAllowlistsNoHost loads configs/config.example.yml
// with a real signing key, as Getting Started in README.md has it copied // with a real signing key, as Getting Started in README.md has it copied
// and edited, and checks that it allowlists no host: every image on an // and edited, and checks that it allowlists no host: every image on an
@@ -15,36 +15,8 @@ import (
_ "modernc.org/sqlite" // SQLite driver registration _ "modernc.org/sqlite" // SQLite driver registration
) )
// The config keys the tests name in the errors they expect.
const (
keyDebug = "debug"
keyMaintenanceMode = "maintenance_mode"
keyPort = "port"
keyStateDir = "state_dir"
keySentryDSN = "sentry_dsn"
keyDBURL = "db_url"
keyMetrics = "metrics"
keyMetricsUsername = "metrics.username"
keyMetricsPassword = "metrics.password"
keySigningKey = "signing_key"
keyAllowlistHosts = "allowlist_hosts"
keyRefererBlocklist = "referer_blocklist"
keyAllowHTTP = "allow_http"
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
keyUpstreamConnections = "upstream_connections"
keyMaxConcurrentProcessing = "max_concurrent_processing"
keyCacheMaxBytes = "cache_max_bytes"
keyBlockedNetworks = "blocked_networks"
keyTrustedProxies = "trusted_proxies"
keyAccessControlAllowOrigin = "access_control_allow_origin"
keyUpstreamFetchTimeout = "upstream_fetch_timeout"
keyUpstreamMaxResponseSize = "upstream_max_response_size"
keyDownstreamTimeout = "downstream_timeout"
keyImageRequestsPerMinute = "image_requests_per_minute"
)
// validTestSigningKey is a 32-character signing key that satisfies the // validTestSigningKey is a 32-character signing key that satisfies the
// minimum length requirement in validateSigningKey. // minimum length requirement in validate().
const validTestSigningKey = "0123456789abcdef0123456789abcdef" const validTestSigningKey = "0123456789abcdef0123456789abcdef"
// signingKeyLine is a valid signing_key config line used as the base of // signingKeyLine is a valid signing_key config line used as the base of
@@ -1,127 +0,0 @@
package config
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// TestReadmeNamesEverySetting checks that the list of settings in
// README.md names exactly the keys of the settings table, one item each.
// The list is the first one in the Configuration section, and it ends at
// the first blank line. Each item starts with its key in backticks.
func TestReadmeNamesEverySetting(t *testing.T) {
t.Parallel()
readme, err := os.ReadFile(filepath.Join("..", "..", "README.md"))
if err != nil {
t.Fatalf("failed to read README.md: %v", err)
}
_, section, found := strings.Cut(string(readme), "\n### Configuration\n")
if !found {
t.Fatal("README.md has no Configuration section")
}
// The section ends at the next heading of its level or above.
section, _, _ = strings.Cut(section, "\n## ")
section, _, _ = strings.Cut(section, "\n### ")
_, list, found := strings.Cut(section, "\n- ")
if !found {
t.Fatal("README.md's Configuration section has no list")
}
list, _, _ = strings.Cut("- "+list, "\n\n")
itemKey := regexp.MustCompile("^- `([^`]+)`")
named := map[string]bool{}
for line := range strings.SplitSeq(list, "\n") {
// A line that does not start with "- " goes on with the item above.
if !strings.HasPrefix(line, "- ") {
continue
}
match := itemKey.FindStringSubmatch(line)
if match == nil {
t.Errorf("README.md's list of settings has an item "+
"that does not start with a key in backticks: %s", line)
continue
}
if named[match[1]] {
t.Errorf("README.md's list of settings names %s twice", match[1])
}
named[match[1]] = true
}
for _, s := range new(Config).settings() {
if !named[s.key] {
t.Errorf("README.md's list of settings does not name %s", s.key)
}
delete(named, s.key)
}
for key := range named {
t.Errorf("README.md's list of settings names %s, "+
"which is not a setting", key)
}
}
// TestExampleConfigNamesEverySetting checks that
// configs/config.example.yml names exactly the keys of the settings
// table, each one set or commented out with "# ". A key line holds the
// key, a colon and at most a one-word value, which tells it from a
// comment line that starts with a word and a colon. An indented key is
// inside the map of the key above it, as username is inside metrics.
func TestExampleConfigNamesEverySetting(t *testing.T) {
t.Parallel()
const examplePath = "configs/config.example.yml"
example, err := os.ReadFile(filepath.Join("..", "..", examplePath))
if err != nil {
t.Fatalf("failed to read %s: %v", examplePath, err)
}
keyLine := regexp.MustCompile(`(?m)^(?:# )?( *)([a-z_]+):(?: \S+)?$`)
named := map[string]bool{}
var mapKey string
for _, match := range keyLine.FindAllStringSubmatch(string(example), -1) {
indent, key := match[1], match[2]
if indent == "" {
mapKey = key
named[key] = true
continue
}
// The key holding the map is not itself a setting.
delete(named, mapKey)
named[mapKey+"."+key] = true
}
// The env section sets environment variables; it is not a setting.
delete(named, "env")
for _, s := range new(Config).settings() {
if !named[s.key] {
t.Errorf("%s does not name %s", examplePath, s.key)
}
delete(named, s.key)
}
for key := range named {
t.Errorf("%s names %s, which is not a setting", examplePath, key)
}
}
+3 -4
View File
@@ -138,8 +138,7 @@ func TestPixaPortAbortsStartupPointingToPort(t *testing.T) {
// TestSettingVariablesAndConfigPathAreAccepted checks that every // TestSettingVariablesAndConfigPathAreAccepted checks that every
// setting's variable and PIXA_CONFIG_PATH pass the check for unknown // setting's variable and PIXA_CONFIG_PATH pass the check for unknown
// PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the // PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list.
// settings table.
func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) { func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
// A config file's env section loaded by another test can leave a // A config file's env section loaded by another test can leave a
// PIXA_ variable set for the whole process, so every one is unset // PIXA_ variable set for the whole process, so every one is unset
@@ -160,8 +159,8 @@ func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) {
t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml") t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml")
for _, s := range new(Config).settings() { for _, name := range envVarNames() {
t.Setenv(s.envVar, "") t.Setenv(name, "")
} }
err := validateKnownEnvVars() err := validateKnownEnvVars()
@@ -122,7 +122,7 @@ func writeConcurrently(t *testing.T, db *sql.DB) {
// record again. // record again.
func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error { func writeLikeOneRequest(ctx context.Context, db *sql.DB, key string) error {
_, err := db.ExecContext(ctx, `INSERT INTO source_content _, err := db.ExecContext(ctx, `INSERT INTO source_content
(source_key, size_bytes) VALUES (?, 1)`, key) (source_key, size_bytes, format) VALUES (?, 1, 'jpeg')`, key)
if err != nil { if err != nil {
return fmt.Errorf("storing a source: %w", err) return fmt.Errorf("storing a source: %w", err)
} }
+3 -1
View File
@@ -3,10 +3,12 @@
-- Source images, one row per source URL -- Source images, one row per source URL
-- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key -- Files stored at: cache/sources/<ab>/<cd>/<source_key>, where source_key
-- is the SHA-256 of the source's host, path and query -- is the SHA-256 of the source's host, path and query. format is the
-- image's format, detected from its first bytes when it was fetched.
CREATE TABLE IF NOT EXISTS source_content ( CREATE TABLE IF NOT EXISTS source_content (
source_key TEXT PRIMARY KEY, source_key TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL, size_bytes INTEGER NOT NULL,
format TEXT NOT NULL,
last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP last_accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed CREATE INDEX IF NOT EXISTS idx_source_content_last_accessed
@@ -1,177 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/go-chi/chi/v5/middleware"
"sneak.berlin/go/pixa/internal/httpfetcher"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// heldFetcher sends on started when a fetch begins, and holds the fetch until
// release is closed. It then fails the fetch as an upstream error.
type heldFetcher struct {
started chan struct{}
release chan struct{}
}
// Fetch sends on f.started, waits for f.release to be closed, and returns
// httpfetcher.ErrUpstreamError.
func (f heldFetcher) Fetch(
context.Context, imagerequest.Source,
) (*httpfetcher.FetchResult, error) {
f.started <- struct{}{}
<-f.release
return nil, httpfetcher.ErrUpstreamError
}
// readLogLines decodes the JSON log lines in logs.
func readLogLines(t *testing.T, logs *bytes.Buffer) []map[string]any {
t.Helper()
t.Logf("logged:\n%s", logs.String())
var lines []map[string]any
dec := json.NewDecoder(logs)
for dec.More() {
var line map[string]any
err := dec.Decode(&line)
if err != nil {
t.Fatalf("decoding log line: %v", err)
}
lines = append(lines, line)
}
return lines
}
// TestImageRoutes_ClientGoneWhileWaitingAnswers499 checks that both image
// routes answer 499 to a request whose client goes away while it waits for an
// image another request is making. A first request's fetch is held, so the
// image is being made, and a second request for the same image waits for it
// until its client goes away. The second request must be logged once at info
// level with its request ID and the source host and path. Nothing may be
// logged at error level.
func TestImageRoutes_ClientGoneWhileWaitingAnswers499(t *testing.T) {
t.Parallel()
const requestID = "test-request-id"
for route, imageURL := range photoURLsByRoute() {
t.Run(route, func(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
fetcher := heldFetcher{started: make(chan struct{}), release: make(chan struct{})}
h, routes := newRoutesFetchingThrough(t, fetcher,
slog.New(slog.NewJSONHandler(&logs, nil)))
target := imageURL(t, h)
firstDone := make(chan struct{})
go func() {
defer close(firstDone)
sendGet(t, routes, target)
}()
<-fetcher.started
ctx, cancel := context.WithCancel(context.WithValue(t.Context(),
middleware.RequestIDKey, requestID))
rec := httptest.NewRecorder()
secondDone := make(chan struct{})
go func() {
defer close(secondDone)
routes.ServeHTTP(rec, httptest.NewRequestWithContext(
ctx, http.MethodGet, target, nil))
}()
// Give the second request time to start waiting for the first
// request's image. The fetch is still held, so the second request
// can end only because its client went away
time.Sleep(100 * time.Millisecond)
cancel()
<-secondDone
close(fetcher.release)
<-firstDone
if rec.Code != 499 {
t.Errorf("status = %d, want 499", rec.Code)
}
var clientGoneLines int
for _, line := range readLogLines(t, &logs) {
if line["level"] == slog.LevelError.String() {
t.Errorf("logged at error level: %v", line["msg"])
}
if line["level"] == slog.LevelInfo.String() &&
line["msg"] == "client went away before the image was ready" &&
line["request_id"] == requestID &&
line["host"] == signedHost &&
line["path"] == photoPath {
clientGoneLines++
}
}
if clientGoneLines != 1 {
t.Errorf("info line naming the request logged %d times, want 1",
clientGoneLines)
}
})
}
}
// TestImageRoutes_CanceledWithLiveRequestAnswers500 checks that both image
// routes answer 500, logged as "failed to get image" at error level, for an
// image service error that wraps context.Canceled while the request's own
// context is live. Its client has not gone away, so the cancellation is
// pixa's own failure.
func TestImageRoutes_CanceledWithLiveRequestAnswers500(t *testing.T) {
t.Parallel()
for route, imageURL := range photoURLsByRoute() {
t.Run(route, func(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
h, routes := newRoutesFetchingThrough(t,
failingFetcher{err: context.Canceled},
slog.New(slog.NewJSONHandler(&logs, nil)))
rec := sendGet(t, routes, imageURL(t, h))
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d",
rec.Code, http.StatusInternalServerError)
}
if !slices.ContainsFunc(readLogLines(t, &logs),
func(line map[string]any) bool {
return line["level"] == slog.LevelError.String() &&
line["msg"] == "failed to get image"
}) {
t.Error(`no "failed to get image" line logged at error level`)
}
})
}
}
+1 -24
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -20,12 +19,6 @@ import (
"sneak.berlin/go/pixa/internal/magic" "sneak.berlin/go/pixa/internal/magic"
) )
// statusClientClosedRequest is the status nginx calls "client closed
// request". The image routes answer it when the client goes away before its
// image is ready, unless its request is the one making the image. Nobody reads
// it; it is for logs and metrics.
const statusClientClosedRequest = 499
// HandleImage handles the main image proxy route: // HandleImage handles the main image proxy route:
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format // /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
// /v1/image/<host>/<path>/<width>x<height> // /v1/image/<host>/<path>/<width>x<height>
@@ -352,8 +345,7 @@ func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
// serveImage serves the image that req asks for. Both image routes call it // serveImage serves the image that req asks for. Both image routes call it
// once they have the request and its URL's expiry, which ends the // once they have the request and its URL's expiry, which ends the
// Cache-Control max-age. It chooses the format for the format auto, answers // Cache-Control max-age. It chooses the format for the format auto, answers
// If-None-Match with 304 and HEAD with the headers only. It answers 499 when // If-None-Match with 304 and HEAD with the headers only, and answers an error
// the image service stopped because the client went away, and any other error
// of the image service with respondImageError. // of the image service with respondImageError.
func (s *Handlers) serveImage( func (s *Handlers) serveImage(
w http.ResponseWriter, r *http.Request, w http.ResponseWriter, r *http.Request,
@@ -370,21 +362,6 @@ func (s *Handlers) serveImage(
startTime := time.Now() startTime := time.Now()
resp, err := s.imgSvc.Get(r.Context(), req) resp, err := s.imgSvc.Get(r.Context(), req)
// The client went away before its image was ready, which is not pixa's
// failure. A cancellation while the request's context is live is pixa's,
// and respondImageError answers it 500.
if errors.Is(err, context.Canceled) && r.Context().Err() != nil {
s.log.Info("client went away before the image was ready",
"request_id", middleware.GetReqID(r.Context()),
"host", req.Source.Host,
"path", req.Source.Path,
)
w.WriteHeader(statusClientClosedRequest)
return
}
if err != nil { if err != nil {
s.respondImageError(w, req, err) s.respondImageError(w, req, err)
+9 -29
View File
@@ -113,8 +113,7 @@ var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ErrUnsupportedInputFormat is returned, before the input is decoded, when // ErrUnsupportedInputFormat is returned, before the input is decoded, when
// libvips reads the input's first bytes as another format than its source // libvips reads the input's first bytes as another format than its source
// format, or as none. An SVG whose first bytes pass the first-bytes check for // format, or as none. An SVG whose first bytes pass the first-bytes check for
// AVIF is one example. A source format other than the six pixa reads is also // AVIF is one example.
// refused with it.
var ErrUnsupportedInputFormat = errors.New("unsupported input format") var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being // ErrTooManyImages is returned when MaxConcurrentProcessing images are being
@@ -183,11 +182,14 @@ func (p *ImageProcessor) Process(
return nil, err return nil, err
} }
// Input libvips would not load as sourceFormat is refused before it is // govips picks the libvips loader from the input's first bytes with
// decoded // DetermineImageType. Input it would not load with the loader of
err = checkInputFormat(data, sourceFormat) // sourceFormat is refused before libvips reads it. An SVG whose first
if err != nil { // bytes pass the first-bytes check for AVIF is one, which librsvg would
return nil, err // otherwise render.
if vips.DetermineImageType(data) != vipsImageType(sourceFormat) {
return nil, fmt.Errorf("%w: libvips does not read the input as %s",
ErrUnsupportedInputFormat, sourceFormat)
} }
// Decode image // Decode image
@@ -358,28 +360,6 @@ func (p *ImageProcessor) readInput(input io.Reader) ([]byte, error) {
return data, nil return data, nil
} }
// checkInputFormat returns ErrUnsupportedInputFormat unless libvips would
// load data with the loader of sourceFormat. govips picks the libvips loader
// from the input's first bytes with DetermineImageType. It refuses an SVG
// whose first bytes pass the first-bytes check for AVIF, which librsvg would
// otherwise render.
func checkInputFormat(data []byte, sourceFormat imagerequest.Format) error {
// A format with no loader, such as orig or auto, has the type govips also
// gives input it cannot read, which the check below would let through.
sourceType := vipsImageType(sourceFormat)
if sourceType == vips.ImageTypeUnknown {
return fmt.Errorf("%w: %q is not a source format",
ErrUnsupportedInputFormat, sourceFormat)
}
if vips.DetermineImageType(data) != sourceType {
return fmt.Errorf("%w: libvips does not read the input as %s",
ErrUnsupportedInputFormat, sourceFormat)
}
return nil
}
// vipsImageType returns the govips image type whose libvips loader reads // vipsImageType returns the govips image type whose libvips loader reads
// format. orig and auto stand for another format, so they have // format. orig and auto stand for another format, so they have
// vips.ImageTypeUnknown, which govips has no loader for. // vips.ImageTypeUnknown, which govips has no loader for.
@@ -2,7 +2,6 @@ package imageprocessor
import ( import (
"bytes" "bytes"
"errors"
"io" "io"
"testing" "testing"
@@ -49,27 +48,3 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
} }
} }
} }
// TestImageProcessor_NotASourceFormatRefused verifies that a source format
// other than the six, such as orig, auto or none, is refused with
// ErrUnsupportedInputFormat. The input is text, which libvips reads as no
// format.
func TestImageProcessor_NotASourceFormatRefused(t *testing.T) {
t.Parallel()
proc := New(Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1})
for _, sourceFormat := range []imagerequest.Format{
imagerequest.FormatOriginal, imagerequest.FormatAuto, "",
} {
result, err := proc.Process(t.Context(),
bytes.NewReader([]byte("this is text, not an image")), sourceFormat,
&imagerequest.Transform{Format: imagerequest.FormatJPEG})
t.Logf("source format %q: error = %v", sourceFormat, err)
if result != nil || !errors.Is(err, ErrUnsupportedInputFormat) {
t.Errorf("Process() with source format %q = %v, error %v; want %v",
sourceFormat, result, err, ErrUnsupportedInputFormat)
}
}
}
+11 -5
View File
@@ -303,10 +303,9 @@ func withoutCancelKeepingDeadline(
return context.WithDeadline(context.WithoutCancel(ctx), deadline) return context.WithDeadline(context.WithoutCancel(ctx), deadline)
} }
// loadCachedSource opens src's cached source, reading only the first bytes, // loadCachedSource opens src's cached source, without reading it, and
// which give its format, and returns it with its size and its format. It // returns it with its size and its format; nil if it is not in the cache,
// returns nil if the source is not in the cache, cannot be read, is in no // cannot be read, is empty or exceeds maxResponseSize.
// source format or exceeds maxResponseSize.
func (p *Proxy) loadCachedSource( func (p *Proxy) loadCachedSource(
ctx context.Context, src imagerequest.Source, ctx context.Context, src imagerequest.Source,
) (io.ReadCloser, int64, imagerequest.Format) { ) (io.ReadCloser, int64, imagerequest.Format) {
@@ -334,6 +333,12 @@ func (p *Proxy) loadCachedSource(
return nil, 0, "" return nil, 0, ""
} }
if size == 0 {
_ = reader.Close()
return nil, 0, ""
}
return reader, size, format return reader, size, format
} }
@@ -452,7 +457,8 @@ func (p *Proxy) fetchAndProcess(
} }
// Store source content // Store source content
_, err = p.cache.PutSource(ctx, req.Source, bytes.NewReader(sourceData)) _, err = p.cache.PutSource(ctx, req.Source, sourceFormat,
bytes.NewReader(sourceData))
if err != nil { if err != nil {
p.log.Warn("failed to store source content", "error", err) p.log.Warn("failed to store source content", "error", err)
// Continue even if caching fails // Continue even if caching fails
@@ -58,11 +58,10 @@ func holdProcessingSlot(
// TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a // TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a
// request whose source is cached holds none of it while it waits for a // request whose source is cached holds none of it while it waits for a
// processing slot: past the first bytes, which give the source's format, it // processing slot: it reads the cached file only once it has a slot. With
// reads the cached file only once it has a slot. With the only slot held, a // the only slot held, a request for a new width of the cached 100x100 photo
// request for a new width of the cached 100x100 photo waits; the cached file // waits; the cached file is then rewritten as a 100x50 image before the slot
// is then rewritten as a 100x50 image before the slot is freed, so the // is freed, so the request must answer with that image scaled to 40x20.
// request must answer with that image scaled to 40x20.
func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) { func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
t.Parallel() t.Parallel()
@@ -7,7 +7,6 @@ import (
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/imgcache"
) )
// getImage calls Get for req and returns the image it answered with and // getImage calls Get for req and returns the image it answered with and
@@ -105,40 +104,6 @@ func TestProxy_Get_FetchesAgainForRemovedCachedSource(t *testing.T) {
} }
} }
// TestProxy_Get_FetchesAgainSourceOfNoSourceFormat replaces the cached
// source's file with text. The next request for a new size of the source
// must fetch it again and cache it whole.
func TestProxy_Get_FetchesAgainSourceOfNoSourceFormat(t *testing.T) {
t.Parallel()
svc, fixtures := setupTestProxy(t)
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
wholeSize := fileSize(t, path)
err := os.WriteFile(path, []byte("this is text, not an image"),
imgcache.StorageFilePerm)
if err != nil {
t.Fatalf("replacing the source's file: %v", err)
}
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
fetches := readCounters(t, svc).upstreamFetchCount
t.Logf("upstream fetches = %d", fetches)
if fetches != 2 {
t.Errorf("upstream fetches = %d, want 2", fetches)
}
size := fileSize(t, path)
if size != wholeSize {
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
}
}
// TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of // TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of
// the photo and removes its row and file, as the reconciliation pass does // the photo and removes its row and file, as the reconciliation pass does
// when the file is empty or cut short. internal/imgcache tests that the // when the file is empty or cut short. internal/imgcache tests that the
+21 -26
View File
@@ -228,10 +228,9 @@ func (c *Cache) PutVariant(
} }
// Source opens the cached source of src and returns a reader, its size in // Source opens the cached source of src and returns a reader, its size in
// bytes and its format, read from the file's first bytes. A source whose row // bytes and its format, the one PutSource stored with it. A source whose row
// or file is missing is ErrNotFound, as is any source of a disabled cache; a // or file is missing is ErrNotFound, as is any source of a disabled cache. A
// file whose first bytes are not those of a source format is another error. // source it opens has its access time handed to the background writer
// A source it opens has its access time handed to the background writer
// without waiting (see touchSourceContent). // without waiting (see touchSourceContent).
func (c *Cache) Source( func (c *Cache) Source(
ctx context.Context, src imagerequest.Source, ctx context.Context, src imagerequest.Source,
@@ -242,38 +241,32 @@ func (c *Cache) Source(
key := src.Key() key := src.Key()
var rowExists bool var format imagerequest.Format
err := c.db.QueryRowContext(ctx, ` err := c.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM source_content WHERE source_key = ?) SELECT format FROM source_content WHERE source_key = ?
`, string(key)).Scan(&rowExists) `, string(key)).Scan(&format)
if errors.Is(err, sql.ErrNoRows) {
return nil, 0, "", ErrNotFound
}
if err != nil { if err != nil {
return nil, 0, "", fmt.Errorf("failed to lookup source: %w", err) return nil, 0, "", fmt.Errorf("failed to lookup source: %w", err)
} }
if !rowExists {
return nil, 0, "", ErrNotFound
}
file, size, err := c.srcContent.Open(string(key)) file, size, err := c.srcContent.Open(string(key))
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, "", err
} }
format, err := readFormat(file)
if err != nil {
_ = file.Close()
return nil, 0, "", err
}
c.touchSourceContent(key) c.touchSourceContent(key)
return file, size, format, nil return file, size, format, nil
} }
// PutSource stores fetched source content: it writes the file named by // PutSource stores fetched source content and its format, which magic
// the source's key, then the source's row. When the row cannot be written // detected from its first bytes. It writes the file named by the source's
// key, then the source's row with the format. When the row cannot be written
// the file is removed and the store fails, as a file with no row is never // the file is removed and the store fails, as a file with no row is never
// served or evicted; if that removal fails too, the next reconciliation // served or evicted; if that removal fails too, the next reconciliation
// pass removes the file. On a disabled cache it is a no-op returning an // pass removes the file. On a disabled cache it is a no-op returning an
@@ -281,6 +274,7 @@ func (c *Cache) Source(
func (c *Cache) PutSource( func (c *Cache) PutSource(
ctx context.Context, ctx context.Context,
src imagerequest.Source, src imagerequest.Source,
format imagerequest.Format,
content io.Reader, content io.Reader,
) (imagerequest.SourceKey, error) { ) (imagerequest.SourceKey, error) {
if c.config.MaxBytes == 0 { if c.config.MaxBytes == 0 {
@@ -302,12 +296,13 @@ func (c *Cache) PutSource(
size := int64(len(data)) size := int64(len(data))
_, err = c.db.ExecContext(ctx, ` _, err = c.db.ExecContext(ctx, `
INSERT INTO source_content (source_key, size_bytes) INSERT INTO source_content (source_key, size_bytes, format)
VALUES (?, ?) VALUES (?, ?, ?)
ON CONFLICT(source_key) DO UPDATE SET ON CONFLICT(source_key) DO UPDATE SET
size_bytes = excluded.size_bytes, size_bytes = excluded.size_bytes,
format = excluded.format,
last_accessed_at = CURRENT_TIMESTAMP last_accessed_at = CURRENT_TIMESTAMP
`, string(key), size) `, string(key), size, string(format))
if err != nil { if err != nil {
deleteErr := c.srcContent.Delete(string(key)) deleteErr := c.srcContent.Delete(string(key))
if deleteErr != nil { if deleteErr != nil {
@@ -449,9 +444,9 @@ func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) {
return deleted, nil return deleted, nil
} }
// readFormat reads the first bytes of a source or variant file, without // readFormat reads the first bytes of content, without moving its offset,
// moving its offset, and returns the format they show. Sources and variants // and returns the format they show. pixa writes each format it reads, so
// are in the same six formats. // that is an output format.
func readFormat(content io.ReaderAt) (imagerequest.Format, error) { func readFormat(content io.ReaderAt) (imagerequest.Format, error) {
firstBytes := make([]byte, magic.MinMagicBytes) firstBytes := make([]byte, magic.MinMagicBytes)
+34 -50
View File
@@ -515,15 +515,12 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
} }
// reconcileFiles walks baseDir, one of the cache's storage directories, // reconcileFiles walks baseDir, one of the cache's storage directories,
// sweeping stale temp files and passing each other file, with its info and // sweeping stale temp files and passing each other file, with its name, to
// name, to reconcileFile. It skips a directory it cannot list and a file // reconcileFile. Once ctx is cancelled it stops at the next file and
// whose info it cannot read, so one such entry does not stop the pass. It // returns ctx's error.
// logs a warning naming the path and the error. A file renamed or removed
// after its directory was listed is skipped with no warning. Once ctx is
// cancelled it stops at the next file and returns ctx's error.
func (c *Cache) reconcileFiles( func (c *Cache) reconcileFiles(
ctx context.Context, baseDir string, ctx context.Context, baseDir string,
reconcileFile func(path string, info fs.FileInfo, name string) error, reconcileFile func(path string, entry fs.DirEntry, name string) error,
) error { ) error {
return filepath.WalkDir( return filepath.WalkDir(
baseDir, baseDir,
@@ -532,38 +529,19 @@ func (c *Cache) reconcileFiles(
return ctx.Err() return ctx.Err()
} }
if err != nil { if err != nil || entry.IsDir() {
c.log.Warn("skipped unreadable entry in cache directory", return err
"path", path, "error", err)
return nil
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
c.log.Warn("skipped unreadable entry in cache directory",
"path", path, "error", err)
return nil
} }
name := entry.Name() name := entry.Name()
if strings.HasPrefix(name, tempFilePrefix) { if strings.HasPrefix(name, tempFilePrefix) {
c.sweepStaleTempFile(path, info) c.sweepStaleTempFile(path, entry)
return nil return nil
} }
return reconcileFile(path, info, name) return reconcileFile(path, entry, name)
}, },
) )
} }
@@ -573,8 +551,8 @@ func (c *Cache) reconcileFiles(
// and sweeping stale temp files. // and sweeping stale temp files.
func (c *Cache) reconcileVariantFiles(ctx context.Context) error { func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
return c.reconcileFiles(ctx, c.variants.baseDir, return c.reconcileFiles(ctx, c.variants.baseDir,
func(path string, info fs.FileInfo, name string) error { func(path string, entry fs.DirEntry, name string) error {
return c.reconcileVariantFile(ctx, path, info, imagerequest.VariantKey(name)) return c.reconcileVariantFile(ctx, path, entry, imagerequest.VariantKey(name))
}) })
} }
@@ -584,11 +562,16 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
// crash or a restored snapshot can leave it, is removed with its row and // crash or a restored snapshot can leave it, is removed with its row and
// a warning naming it, so the variant is converted again. // a warning naming it, so the variant is converted again.
func (c *Cache) reconcileVariantFile( func (c *Cache) reconcileVariantFile(
ctx context.Context, path string, info fs.FileInfo, cacheKey imagerequest.VariantKey, ctx context.Context, path string, entry fs.DirEntry, cacheKey imagerequest.VariantKey,
) error { ) error {
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat variant file: %w", err)
}
var recordedSize int64 var recordedSize int64
err := c.db.QueryRowContext(ctx, err = c.db.QueryRowContext(ctx,
`SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey), `SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey),
).Scan(&recordedSize) ).Scan(&recordedSize)
@@ -630,9 +613,7 @@ func (c *Cache) adoptVariantFile(
} }
// reconcileVariantRows drops accounting rows whose variant files are // reconcileVariantRows drops accounting rows whose variant files are
// missing, so the database never references deleted content. A file whose // missing, so the database never references deleted content.
// size cannot be read for another reason may still be on disk, so its row
// stays, with a warning naming its key and the error.
func (c *Cache) reconcileVariantRows(ctx context.Context) error { func (c *Cache) reconcileVariantRows(ctx context.Context) error {
var after imagerequest.VariantKey var after imagerequest.VariantKey
@@ -660,13 +641,6 @@ func (c *Cache) reconcileVariantRows(ctx context.Context) error {
continue continue
} }
if !errors.Is(err, ErrNotFound) {
c.log.Warn("kept accounting row for unreadable variant file",
"cache_key", key, "error", err)
continue
}
_, err = c.db.ExecContext(ctx, _, err = c.db.ExecContext(ctx,
`DELETE FROM variant_content WHERE cache_key = ?`, string(key)) `DELETE FROM variant_content WHERE cache_key = ?`, string(key))
if err != nil { if err != nil {
@@ -731,8 +705,8 @@ func queryStringColumn[T ~string](
// and sweeping stale temp files. // and sweeping stale temp files.
func (c *Cache) reconcileSourceFiles(ctx context.Context) error { func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
return c.reconcileFiles(ctx, c.srcContent.baseDir, return c.reconcileFiles(ctx, c.srcContent.baseDir,
func(path string, info fs.FileInfo, name string) error { func(path string, entry fs.DirEntry, name string) error {
return c.reconcileSourceFile(ctx, path, info, imagerequest.SourceKey(name)) return c.reconcileSourceFile(ctx, path, entry, imagerequest.SourceKey(name))
}) })
} }
@@ -743,11 +717,16 @@ func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
// is removed with its row and a warning naming it, so the source is // is removed with its row and a warning naming it, so the source is
// fetched again. // fetched again.
func (c *Cache) reconcileSourceFile( func (c *Cache) reconcileSourceFile(
ctx context.Context, path string, info fs.FileInfo, key imagerequest.SourceKey, ctx context.Context, path string, entry fs.DirEntry, key imagerequest.SourceKey,
) error { ) error {
info, err := entry.Info()
if err != nil {
return fmt.Errorf("failed to stat source content file: %w", err)
}
var recordedSize int64 var recordedSize int64
err := c.db.QueryRowContext(ctx, err = c.db.QueryRowContext(ctx,
`SELECT size_bytes FROM source_content WHERE source_key = ?`, `SELECT size_bytes FROM source_content WHERE source_key = ?`,
string(key), string(key),
).Scan(&recordedSize) ).Scan(&recordedSize)
@@ -912,12 +891,17 @@ func (c *Cache) correctUsageTotal(
// sweepStaleTempFile removes a temp file left behind by a crashed // sweepStaleTempFile removes a temp file left behind by a crashed
// write once it is old enough that no in-flight store can own it. // write once it is old enough that no in-flight store can own it.
func (c *Cache) sweepStaleTempFile(path string, info fs.FileInfo) { func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
info, err := entry.Info()
if err != nil {
return
}
if time.Since(info.ModTime()) < staleTempFileAge { if time.Since(info.ModTime()) < staleTempFileAge {
return return
} }
err := os.Remove(path) err = os.Remove(path)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
c.log.Warn("failed to remove stale temp file", "path", path, "error", err) c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
+7 -291
View File
@@ -9,10 +9,8 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"syscall"
"testing" "testing"
"time" "time"
@@ -81,8 +79,9 @@ func newEvictionTestCache(t *testing.T, maxBytes int64) (*Cache, string) {
return cache, tmpDir return cache, tmpDir
} }
// storeEvictionTestSource stores content as a fetched source for // storeEvictionTestSource stores content as a fetched JPEG source for
// host/path and returns the resulting source key. // host/path and returns the resulting source key. The cache does not read
// the content, so it need not be a JPEG.
func storeEvictionTestSource( func storeEvictionTestSource(
t *testing.T, cache *Cache, host, path string, content []byte, t *testing.T, cache *Cache, host, path string, content []byte,
) imagerequest.SourceKey { ) imagerequest.SourceKey {
@@ -101,7 +100,8 @@ func storeEvictionTestSource(
} }
key, err := cache.PutSource( key, err := cache.PutSource(
context.Background(), req.Source, bytes.NewReader(content), context.Background(), req.Source, imagerequest.FormatJPEG,
bytes.NewReader(content),
) )
if err != nil { if err != nil {
t.Fatalf("PutSource(%s%s) failed: %v", host, path, err) t.Fatalf("PutSource(%s%s) failed: %v", host, path, err)
@@ -438,7 +438,8 @@ func assertDisabledCacheWritesAreNoOps(
t.Fatalf("PutVariant on disabled cache must be a no-op, got error: %v", err) t.Fatalf("PutVariant on disabled cache must be a no-op, got error: %v", err)
} }
key, err := cache.PutSource(ctx, req.Source, bytes.NewReader([]byte("data"))) key, err := cache.PutSource(ctx, req.Source, imagerequest.FormatJPEG,
bytes.NewReader([]byte("data")))
if err != nil { if err != nil {
t.Fatalf("PutSource on disabled cache must be a no-op, got error: %v", err) t.Fatalf("PutSource on disabled cache must be a no-op, got error: %v", err)
} }
@@ -701,291 +702,6 @@ func TestStartEvictionReconcilesAccountingWithDisk(t *testing.T) {
} }
} }
// logLineNaming returns the first line of logs, written by slog's JSON
// handler, that is at level and names the variant key, or "" if none is.
func logLineNaming(logs, level string, key imagerequest.VariantKey) string {
for line := range strings.Lines(logs) {
if strings.Contains(line, `"level":"`+level+`"`) &&
strings.Contains(line, `"cache_key":"`+string(key)+`"`) {
return line
}
}
return ""
}
// TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles gives
// reconcileVariantRows two tracked variants. The first one's file is a
// symbolic link to itself, so its size cannot be read. Its row must stay,
// with a warning naming its key and the error. The second one has no file.
// Its row must go, with the info line for a missing file.
func TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestVariant(t, cache, testVariantKeyOne, []byte("variant one"))
storeEvictionTestVariant(t, cache, testVariantKeyTwo, []byte("variant two"))
unreadablePath := cache.variants.keyToPath(string(testVariantKeyOne))
err := os.Remove(unreadablePath)
if err != nil {
t.Fatalf("failed to remove the first variant's file: %v", err)
}
err = os.Symlink(unreadablePath, unreadablePath)
if err != nil {
t.Fatalf("failed to link the first variant's path to itself: %v", err)
}
_, statErr := os.Stat(unreadablePath)
if statErr == nil || errors.Is(statErr, fs.ErrNotExist) {
t.Fatalf("os.Stat() of the first variant: error = %v, "+
"want an error other than a missing file", statErr)
}
err = os.Remove(cache.variants.keyToPath(string(testVariantKeyTwo)))
if err != nil {
t.Fatalf("failed to remove the second variant's file: %v", err)
}
var logBuf bytes.Buffer
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
err = cache.reconcileVariantRows(t.Context())
t.Logf("reconcileVariantRows() error = %v, log: %s", err, logBuf.String())
if err != nil {
t.Fatalf("reconcileVariantRows() error = %v", err)
}
rowQuery := `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`
if n := countRows(t, cache, rowQuery, string(testVariantKeyOne)); n != 1 {
t.Errorf("the variant whose size cannot be read has %d rows, want 1", n)
}
if n := countRows(t, cache, rowQuery, string(testVariantKeyTwo)); n != 0 {
t.Errorf("the variant with no file has %d rows, want 0", n)
}
logs := logBuf.String()
warning := logLineNaming(logs, "WARN", testVariantKeyOne)
if !strings.Contains(warning, statErr.Error()) {
t.Errorf("no warning names %s and the error %q", testVariantKeyOne, statErr)
}
if line := logLineNaming(logs, "INFO", testVariantKeyOne); line != "" {
t.Errorf("the variant whose size cannot be read was logged at info: %s", line)
}
dropped := logLineNaming(logs, "INFO", testVariantKeyTwo)
if !strings.Contains(dropped, "dropped accounting row for missing variant file") {
t.Errorf("no info line says the file of %s is missing", testVariantKeyTwo)
}
}
// openDirectoryNearPathLimit makes a chain of directories inside dir, each
// named with 255 zeros, the longest name Linux allows. Linux refuses a path
// of 4096 bytes or more, and the chain goes as deep as it can below that.
// It returns the chain's deepest directory as an os.Root, closed when the
// test ends. An entry made through it with a 255-byte name has a path even
// the root user cannot read. The os.Root can make that entry because it
// names the entry only by its name, not by the whole path.
func openDirectoryNearPathLimit(t *testing.T, dir string) *os.Root {
t.Helper()
name := strings.Repeat("0", 255)
deepest := dir
for len(filepath.Join(deepest, name)) < 4096 {
deepest = filepath.Join(deepest, name)
}
err := os.MkdirAll(deepest, StorageDirPerm)
if err != nil {
t.Fatalf("failed to make a chain of directories: %v", err)
}
root, err := os.OpenRoot(deepest)
if err != nil {
t.Fatalf("os.OpenRoot(%s) error = %v", deepest, err)
}
t.Cleanup(func() { _ = root.Close() })
return root
}
// makeEntriesTooLongToRead makes a new directory and a new file in the
// deepest directory of the chain that openDirectoryNearPathLimit makes
// inside dir. Even the root user can then neither list the new directory
// nor read the new file's info. It returns the paths of the new directory
// and the new file.
func makeEntriesTooLongToRead(t *testing.T, dir string) []string {
t.Helper()
deepest := openDirectoryNearPathLimit(t, dir)
dirName := strings.Repeat("0", 255)
err := deepest.Mkdir(dirName, StorageDirPerm)
if err != nil {
t.Fatalf("failed to make a directory too deep to list: %v", err)
}
fileName := strings.Repeat("1", 255)
err = deepest.WriteFile(fileName, []byte("unreadable"), StorageFilePerm)
if err != nil {
t.Fatalf("failed to make a file too deep to read: %v", err)
}
return []string{
filepath.Join(deepest.Name(), dirName),
filepath.Join(deepest.Name(), fileName),
}
}
// makeTempFileTooLongToRead makes a temp file in the deepest directory of
// the chain that openDirectoryNearPathLimit makes inside dir. Even the
// root user then cannot read the temp file's info. It returns the temp
// file's path.
func makeTempFileTooLongToRead(t *testing.T, dir string) string {
t.Helper()
deepest := openDirectoryNearPathLimit(t, dir)
name := tempFilePrefix + strings.Repeat("1", 255-len(tempFilePrefix))
err := deepest.WriteFile(name, []byte("partial"), StorageFilePerm)
if err != nil {
t.Fatalf("failed to make a temp file too deep to read: %v", err)
}
return filepath.Join(deepest.Name(), name)
}
// assertSkippedEntryWarning checks that logs hold the warning "skipped
// unreadable entry in cache directory" naming path, with the error
// ENAMETOOLONG for that path.
func assertSkippedEntryWarning(t *testing.T, logs, path string) {
t.Helper()
for line := range strings.Lines(logs) {
if strings.Contains(line, `"level":"WARN"`) &&
strings.Contains(line, `"msg":"skipped unreadable entry in cache directory"`) &&
strings.Contains(line, `"path":"`+path+`"`) &&
strings.Contains(line, path+": "+syscall.ENAMETOOLONG.Error()) {
return
}
}
t.Errorf("no warning %q names %s with the error %q",
"skipped unreadable entry in cache directory", path, syscall.ENAMETOOLONG)
}
// TestReconciliationGoesOnPastEntriesItCannotRead puts, in each storage
// directory, a directory the pass cannot list and a file whose info it
// cannot read. The pass walks each directory in name order, so it reaches
// them before the cache's files, whose keys start with a letter. It must
// log a warning naming each one's path and the error, and still do the
// rest of its work:
// - adopt a variant file that has no row
// - drop the row of a variant whose file is missing
// - remove a source file that has no row
// - correct a wrong total cache usage
func TestReconciliationGoesOnPastEntriesItCannotRead(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
ctx := t.Context()
untrackedVariant := []byte("variant with no row")
err := cache.variants.Write(string(testVariantKeyOne), untrackedVariant)
if err != nil {
t.Fatalf("failed to write a variant file with no row: %v", err)
}
storeEvictionTestVariant(t, cache, testVariantKeyTwo, []byte("variant two"))
err = os.Remove(cache.variants.keyToPath(string(testVariantKeyTwo)))
if err != nil {
t.Fatalf("failed to remove the second variant's file: %v", err)
}
untrackedSourceKey := "ffeeddcc0001"
err = cache.srcContent.Write(untrackedSourceKey, []byte("source with no row"))
if err != nil {
t.Fatalf("failed to write a source file with no row: %v", err)
}
_, err = cache.db.ExecContext(ctx,
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
if err != nil {
t.Fatalf("failed to set a wrong total: %v", err)
}
unreadable := slices.Concat(
makeEntriesTooLongToRead(t, cache.variants.baseDir),
makeEntriesTooLongToRead(t, cache.srcContent.baseDir),
)
logs := reconcileAsAtStartup(t, cache)
for _, path := range unreadable {
assertSkippedEntryWarning(t, logs, path)
}
rowQuery := `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`
if n := countRows(t, cache, rowQuery, string(testVariantKeyOne)); n != 1 {
t.Errorf("the variant file with no row got %d rows, want 1", n)
}
if n := countRows(t, cache, rowQuery, string(testVariantKeyTwo)); n != 0 {
t.Errorf("the variant with no file kept %d rows, want 0", n)
}
_, err = os.Stat(cache.srcContent.keyToPath(untrackedSourceKey))
if err == nil {
t.Error("the source file with no row is still on disk")
}
usage, err := cache.UsageBytes(ctx)
if err != nil {
t.Fatalf("UsageBytes() error = %v", err)
}
if usage != int64(len(untrackedVariant)) {
t.Errorf("UsageBytes() = %d, want %d, the size of the adopted variant",
usage, len(untrackedVariant))
}
}
// TestReconciliationWarnsOfTempFilesWhoseInfoItCannotRead puts, in each
// storage directory, a temp file whose info the pass cannot read. The
// pass must log a warning naming each one's path and the error, as it
// does for any other file whose info it cannot read.
func TestReconciliationWarnsOfTempFilesWhoseInfoItCannotRead(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
unreadable := []string{
makeTempFileTooLongToRead(t, cache.variants.baseDir),
makeTempFileTooLongToRead(t, cache.srcContent.baseDir),
}
logs := reconcileAsAtStartup(t, cache)
for _, path := range unreadable {
assertSkippedEntryWarning(t, logs, path)
}
}
// TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves // TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves
// reconciliation is not a one-shot startup-only pass: it must also run // reconciliation is not a one-shot startup-only pass: it must also run
// on the periodic ticker, so a variant file that lands on disk with no // on the periodic ticker, so a variant file that lands on disk with no
@@ -5,7 +5,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"image/color"
"io" "io"
"log/slog" "log/slog"
"strings" "strings"
@@ -205,8 +204,8 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
t.Parallel() t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30) cache, _ := newEvictionTestCache(t, 1<<30)
content := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255}) key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg",
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg", content) []byte("source bytes"))
anHourAgo := time.Now().Add(-time.Hour) anHourAgo := time.Now().Add(-time.Hour)
setSourceLastAccessed(t, cache, key, anHourAgo) setSourceLastAccessed(t, cache, key, anHourAgo)
@@ -225,7 +224,7 @@ func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
found, err := io.ReadAll(reader) found, err := io.ReadAll(reader)
_ = reader.Close() _ = reader.Close()
if err != nil || !bytes.Equal(found, content) { if err != nil || string(found) != "source bytes" {
t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key) t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key)
} }
@@ -2,17 +2,12 @@ package imgcache
import ( import (
"bytes" "bytes"
"errors"
"fmt"
"image/color"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imageprocessor"
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
"sneak.berlin/go/pixa/internal/magic"
) )
// TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes // TestPutSource_StoresIdenticalBytesOfTwoURLsTwice stores the same bytes
@@ -69,7 +64,7 @@ func TestEvictSourceBlob_LeavesSourceWithIdenticalBytesServed(t *testing.T) {
t.Parallel() t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30) cache, _ := newEvictionTestCache(t, 1<<30)
content := generateTestJPEG(t, 20, 20, color.RGBA{0x5B, 0x5B, 0x5B, 255}) content := bytes.Repeat([]byte{0x5B}, 400)
evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content) evicted := storeEvictionTestSource(t, cache, testHostCDN, "/evicted.jpg", content)
storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content) storeEvictionTestSource(t, cache, testHostCDN, "/kept.jpg", content)
@@ -127,7 +122,7 @@ func TestPutSource_FailedRowWriteLeavesNoFile(t *testing.T) {
req := &imagerequest.Request{ req := &imagerequest.Request{
Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat}, Source: imagerequest.Source{Host: testHostCDN, Path: testPathCat},
} }
key, err := cache.PutSource(t.Context(), req.Source, key, err := cache.PutSource(t.Context(), req.Source, imagerequest.FormatJPEG,
bytes.NewReader([]byte("source bytes"))) bytes.NewReader([]byte("source bytes")))
t.Logf("PutSource() = %q, error = %v", key, err) t.Logf("PutSource() = %q, error = %v", key, err)
@@ -141,98 +136,36 @@ func TestPutSource_FailedRowWriteLeavesNoFile(t *testing.T) {
} }
} }
// TestSource_FormatFromFirstBytes stores an image in each source format // TestSource_ReturnsTheFormatPutSourceStored stores a source as PNG, then
// under one source URL, in turn. After each store, Source must return that // the same source URL again as WebP. After each store, Source must return
// image whole, with its format read from the file. // the format that store gave.
func TestSource_FormatFromFirstBytes(t *testing.T) { func TestSource_ReturnsTheFormatPutSourceStored(t *testing.T) {
t.Parallel() t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30) cache, _ := newEvictionTestCache(t, 1<<30)
processor := imageprocessor.New(imageprocessor.Params{
MaxInputBytes: 50 << 20,
MaxConcurrentProcessing: 1,
})
jpegImage := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
src := imagerequest.Source{Host: testHostCDN, Path: testPathCat} src := imagerequest.Source{Host: testHostCDN, Path: testPathCat}
for _, format := range []imagerequest.Format{ for _, format := range []imagerequest.Format{
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF, imagerequest.FormatPNG, imagerequest.FormatWebP,
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
} { } {
result, err := processor.Process(t.Context(), bytes.NewReader(jpegImage), _, err := cache.PutSource(t.Context(), src, format,
imagerequest.FormatJPEG, &imagerequest.Transform{Format: format}) bytes.NewReader([]byte("source bytes")))
if err != nil {
t.Fatalf("making a %s image: %v", format, err)
}
image, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("reading the %s image: %v", format, err)
}
_, err = cache.PutSource(t.Context(), src, bytes.NewReader(image))
if err != nil { if err != nil {
t.Fatalf("PutSource(%s) error = %v", format, err) t.Fatalf("PutSource(%s) error = %v", format, err)
} }
reader, size, got, err := cache.Source(t.Context(), src) reader, _, got, err := cache.Source(t.Context(), src)
if err != nil { if err != nil {
t.Fatalf("Source() of the %s image error = %v", format, err) t.Fatalf("Source() after PutSource(%s) error = %v", format, err)
} }
served, err := io.ReadAll(reader)
_ = reader.Close() _ = reader.Close()
if err != nil { t.Logf("Source() after PutSource(%s): format %s", format, got)
t.Fatalf("reading the %s source: %v", format, err)
}
t.Logf("%s: %d bytes read as %s", format, size, got)
if got != format { if got != format {
t.Errorf("Source() of the %s image format = %q, want %q", t.Errorf("Source() after PutSource(%s) format = %q, want %q",
format, got, format) format, got, format)
} }
if size != int64(len(image)) || !bytes.Equal(served, image) {
t.Errorf("%s source read as %d bytes, size %d, want the %d bytes stored",
format, len(served), size, len(image))
}
}
}
// TestSource_RefusesFileOfNoSourceFormat checks that Source refuses a
// cached source whose file's first bytes are not those of a source format,
// with an error other than ErrNotFound.
func TestSource_RefusesFileOfNoSourceFormat(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
tests := []struct {
name string
content []byte
wantErr error
}{
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
magic.ErrUnknownFormat},
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
{"empty file", nil, io.EOF},
}
for i, tt := range tests {
path := fmt.Sprintf("/refused%d.jpg", i)
storeEvictionTestSource(t, cache, testHostCDN, path, tt.content)
reader, _, format, err := cache.Source(t.Context(),
imagerequest.Source{Host: testHostCDN, Path: path})
t.Logf("%s: Source() error = %v", tt.name, err)
if reader != nil || format != "" || !errors.Is(err, tt.wantErr) {
t.Errorf("%s: Source() = reader %v, format %q, error %v; "+
"want no reader and %v", tt.name, reader, format, err, tt.wantErr)
}
} }
} }