This is the codebase of the active_storage_validations gem: Active Model validators for Rails Active Storage attachments (presence, content type, size, dimensions, duration, aspect ratio, PDF pages, etc.).
It is a gem, not a Rails application. Specs boot a Combustion dummy app under test/dummy/.
lib/active_storage_validations.rb # Entry point + infer_file_field_accept / command_timeout config
lib/active_storage_validations/
*_validator.rb # Validators (ActiveModel::EachValidator)
base_comparison_validator.rb # Shared comparison options (<, <=, >, >=, between, equal_to)
asv_attachable_adapter.rb # Per-type wrappers for Blob / UploadedFile / Hash / File / Pathname / signed id
shared/asv_*.rb # Shared concerns used by validators
analyzer/ # Media metadata extractors (image/video/audio/pdf)
analyzer/content_type_analyzer/ # Spoofing sniffers: File + Magika backends
extensors/ # Blob metadata + Marcel helpers
matchers/ # Opt-in RSpec/Minitest matchers (consumer-facing)
form_builder.rb # Infers HTML accept from content_type validators
railtie.rb / engine.rb # Rails integration + locale loading
config/locales/*.yml # I18n error messages (all locales must stay in sync)
spec/ # RSpec suite (validators, matchers, analyzers, form_builder, integration)
test/dummy/ # Combustion Rails app + per-validator models
gemfiles/ # Per-Rails-version Bundler Gemfiles (not Appraisal)
benchmark/ # Optional ips / require suite (not shipped in the gem)
docs/upgrade_to_*.md # Upgrade guides
Key integration points (railtie.rb / engine.rb):
Engineregisters the gem soconfig/locales/*.ymlis on the I18n load pathRailtieholds the initializers:- Includes
ActiveStorageValidationsinto Active Record (sovalidates :avatar, attached: trueresolvesAttachedValidator) - Prepends
FormBuilderontoActionView::Helpers::FormBuilder - Includes
ASVBlobMetadatableonActiveStorage::Blob
- Includes
Shared concerns (lib/active_storage_validations/shared/):
| Module | Role |
|---|---|
ASVActiveStorageable |
Attachment presence / attached files |
ASVAttachable |
Loop attachables/blobs; metadata validation path |
ASVAnalyzable |
Pick analyzer; cache results on blob custom metadata (asv_*) |
ASVCommandable |
Timed external commands with process-group kill (command_timeout) |
ASVErrorable |
add_error with validator_type, filename, bounds |
ASVOptionable |
Flatten options; evaluate Proc options |
ASVSymbolizable |
Map validator class → error symbol (:content_type, …) |
ASVLoggable |
Rails.logger helper used by analyzers |
Analyzers depend on optional system tools: ImageMagick or libvips, ffmpeg, poppler, the UNIX file command, and/or the Google Magika CLI (content-type spoofing backends).
Matchers are not auto-loaded. Require them explicitly:
require "active_storage_validations/matchers"- Ruby
>= 3.3(CI also covers 3.4 and 4.0) - Rails
>= 7.0.1(6.1 and 7.0.0 dropped) - See
docs/upgrade_to_4.mdand the Unreleased section ofCHANGES.md
By default, f.file_field :avatar may render an HTML accept attribute derived from content_type validators.
- Disable globally:
ActiveStorageValidations.infer_file_field_accept = false - Disable per field:
f.file_field :avatar, infer_accept: false - Explicit
accept:is never overridden
External analyzer commands (ffprobe, pdfinfo, file, magika, ImageMagick identify, libvips) default to a 10s deadline (ActiveStorageValidations.command_timeout). Override globally, via configure, or per validator with timeout:. Timeouts fail closed (existing validation errors) and emit timeout.active_storage_validations. Matcher: #timeout.
content_type spoofing_protection accepts true / :file (UNIX file CLI) or :magika (Google Magika CLI). Detected types are cached as asv_content_type plus asv_content_type_backend; switching backend re-analyzes. Legacy blobs with only asv_content_type are treated as the :file backend. Matcher: #spoofing_protection / #spoofing_protection(:magika). Install Magika in any environment that uses :magika (CI pins the CLI to cli/v1.1.0 in .github/workflows/main.yml).
A missing file or magika binary raises ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::CommandLineToolNotInstalledError (shared ancestor; both backends resolve that constant).
Matchers support #except_on for Rails :except_on (available since Rails 8.0), e.g. validate_attached_of(:avatar).except_on(:update). Specs that exercise it should guard for Rails < 8.0.
BaseComparisonValidatorMatcher (#less_than, #less_than_or_equal_to, #greater_than, #greater_than_or_equal_to, #between, #equal_to) must probe the inclusive endpoint itself. Checking only min + 1 / max - 1 lets less_than(n) satisfy #less_than_or_equal_to(n). Shared examples: "base comparison validator matcher less_than_or_equal_to rejects less_than", "… greater_than_or_equal_to rejects greater_than", "… equal_to rejects looser comparisons".
AttachedValidator rejects :allow_blank / :allow_nil. Do not add #allow_blank to validate_attached_of or document that chain.
Default task is rake spec (RSpec, pattern spec/**/*_spec.rb).
The root Gemfile pins Rails for bundle exec rake spec without BUNDLE_GEMFILE (currently 8.1.x). Matrix pins live under gemfiles/.
bundle install
bundle exec rake spec
bundle exec rspec spec/validators/size_validator_spec.rb
bundle exec rubocop --parallelFocus examples with :focus / fit / fdescribe (filter_run_when_matching :focus is enabled).
SimpleCov runs unless NO_COVERAGE=1. minimum_coverage is 50 (spec/spec_helper.rb) so a targeted file still finishes; the full suite is ~97%. CI uploads the coverage artifact from the Ruby 3.4 / Rails 8.1 / vips job.
There is no Appraisal gem. Pin Rails with Gemfiles under gemfiles/:
BUNDLE_GEMFILE=gemfiles/rails_7_0_1.gemfile bundle
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle
BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle
BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle
BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle
BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle
BUNDLE_GEMFILE=gemfiles/rails_7_0_1.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake spec
BUNDLE_GEMFILE=gemfiles/rails_next.gemfile bundle exec rake specCI runs both processors. Locally:
IMAGE_PROCESSOR=vips bundle exec rake spec
IMAGE_PROCESSOR=mini_magick bundle exec rake specspec/rails_helper.rb sets config.active_storage.variant_processor from IMAGE_PROCESSOR and disables Active Storage previewers (so CI does not need the image_processing gem). Unset locally: validators use MiniMagick (ASV default) and both image-analyzer unit specs run. When IMAGE_PROCESSOR is set, examples tagged image_processor: :vips / :mini_magick for the other processor are excluded (not pending).
Live Magika examples are gated with magika_cli_available? (FixtureFiles in spec/support/files.rb); most Magika unit specs stub the CLI. Fixture IOs go through open_fixture / register_fixture_io and are closed after each example.
Optional metadata-validator performance suite under benchmark/ (benchmark-ips, informational CI job). Install the :benchmark group, then:
bundle config set --local with benchmark
bundle install
bundle exec ruby benchmark/require.rb
IMAGE_PROCESSOR=vips bundle exec ruby benchmark/metadata_validators.rb
bundle exec ruby benchmark/image_processors.rbSee benchmark/README.md. Update benchmark/BASELINE.md (and the libvips recommendation in README.md if the ratio changes) when changing analyzer / metadata hot paths.
# frozen_string_literal: trueat the top of Ruby files- RuboCop via
rubocop-rails-omakase+rubocop-rspec(see.rubocop.yml); method length max 15 - Shared modules are prefixed
ASV* - Validators define
ERROR_TYPES(and oftenMETADATA_KEYS); add errors throughASVErrorable#add_error - Comparison validators inherit
BaseComparisonValidator— prefer extending that over duplicating option parsing - New / changed I18n keys must be updated in every file under
config/locales/(spec/global/locales_spec.rbasserts keys and English interpolation placeholders) - User-facing changes: update
README.mdand add an entry under Unreleased inCHANGES.md - Breaking changes: also add/update
docs/upgrade_to_X.md - Spec conventions: see
.cursor/rules/spec-*.mdc(validators, matchers, analyzers, integration)
- Implement in
lib/active_storage_validations/<name>_validator.rb - Reuse
shared/asv_*.rband/orBaseComparisonValidatorwhen possible - Update specs under
spec/validators/(prefer existingshared_examples/viait_behaves_like) - Update or add dummy models under
test/dummy/app/models/<validator>/ - If error types change, sync all locale files
- If the public API changes, update README + CHANGES
- Behavior or API changes almost always require updating the related matcher under
lib/active_storage_validations/matchers/and its specs underspec/matchers/
- Implement under
lib/active_storage_validations/matchers/ - Compose concerns from
matchers/shared/(e.g.ASVTimeoutable,ASVExceptOnable,ASVSpoofingProtectable) - Add/update specs under
spec/matchers/ - Matchers filter errors using
validator_type— keep that aligned with the validator - Comparison matchers must keep rejecting exclusive validators from inclusive chains (see Matcher comparison bounds)
- Analyzers live under
lib/active_storage_validations/analyzer/(content-type sniffers underanalyzer/content_type_analyzer/) - Results are cached on the blob via
ASVBlobMetadatableas string keysasv_*(S3 metadata constraints).blob.save!runs only when the blob is already persisted; new blobs keepasv_*in memory until the record is saved - Blobs are treated as immutable: once metadata keys exist, re-analysis is skipped — except content-type spoofing, which also keys the cache on
asv_content_type_backend(filevsmagika) - Run specs with both
IMAGE_PROCESSOR=vipsandIMAGE_PROCESSOR=mini_magickwhen touching image analysis - Content-type sniffer changes usually need both
FileandMagikacoverage underspec/analyzers/content_type_analyzers/ - Missing-CLI errors must keep raising the shared
ContentTypeAnalyzer::CommandLineToolNotInstalledError
| Looking for… | Start here |
|---|---|
| Validator behavior | lib/active_storage_validations/<name>_validator.rb |
| Shared attachable/blob loop | shared/asv_attachable.rb |
| Attachable type dispatch | asv_attachable_adapter.rb (ASVAttachableAdapter) |
| Analysis + caching | shared/asv_analyzable.rb, extensors/asv_blob_metadatable.rb |
| Content-type sniffers | analyzer/content_type_analyzer/{file,magika}.rb (CommandLineToolNotInstalledError on the parent) |
| Error / I18n options | shared/asv_errorable.rb, config/locales/en.yml |
Form accept inference |
form_builder.rb |
| Matcher API | matchers.rb + matchers/<name>_validator_matcher.rb |
| CI matrix truth | .github/workflows/main.yml |
| Dependabot | .github/dependabot.yml (root Gemfile + Actions; not gemfiles/*.gemfile) |
| Version constraints | active_storage_validations.gemspec |
lib/— production code onlyspec/— RSpec suite for the gem (validators, matchers, analyzers, form_builder, integration)test/dummy/— Combustion Rails app used by specsbenchmark/— optional ips / require suite for metadata validators (not shipped in the gem)gemfiles/— Rails version matrix for local/CI runsdocs/— upgrade guides for humans consuming the gemAGENTS.md— this file; agent-oriented contributor guidance (humans can use it too).cursor/rules/— Cursor project rules (commit messages, spec conventions); referenced from this file.cursor/skills/— Cursor Agent skills (e.g./commit); not shipped in the gem- Do not invent an Appraisal setup; keep using
BUNDLE_GEMFILE= - Do not reintroduce a Minitest suite for the gem itself (consumer matchers may still support Minitest)
| Doc | Path |
|---|---|
| User guide | README.md |
| Changelog | CHANGES.md |
| Upgrade to 2.x / 3.x / 4.x | docs/upgrade_to_2.md, docs/upgrade_to_3.md, docs/upgrade_to_4.md |
| Commit messages | .cursor/rules/git.mdc (Conventional Commits; always apply for agents) |
/commit skill |
.cursor/skills/commit/SKILL.md |
| Spec conventions | .cursor/rules/spec.mdc (shared; named subject), plus spec-validators.mdc, spec-matchers.mdc, spec-analyzers.mdc, spec-integration.mdc |
Use .cursor/rules/git.mdc for commit and PR title format. Do not infer style from git log. Slash /commit runs .cursor/skills/commit/SKILL.md.
- Do not reintroduce Rails
< 7.0.1or Ruby< 3.3support without an explicit project decision - Do not auto-require matchers from the main gem entrypoint
- Do not re-enable Active Storage previewers in the dummy app without adding
image_processing - Marcel rejects types like
image/jpg— useimage/jpeg(and Marcel shorthands where registered) - Railtie must not use
after: :load_config_initializers(stack overflow; see comment inrailtie.rb) processable_filemay reject formats with libvips untrusted loaders (e.g. SVG) when Rails setsVips.block_untrusted(true)- Preserve content-type cache backend semantics: legacy
asv_content_typewithoutasv_content_type_backendmust keep hitting the:filecache; switching:file↔:magikamust re-analyze - Magika /
fileare optional system CLIs (not Ruby gems); override paths withActiveStorage.paths[:magika]/ActiveStorage.paths[:file]when needed. Rescue missing tools viaContentTypeAnalyzer::CommandLineToolNotInstalledError(not a per-backend subclass) AttachedValidator#check_validity!rejects:allow_blank/:allow_nil; the attached matcher has no#allow_blank- Comparison matcher inclusive chains (
#less_than_or_equal_to,#greater_than_or_equal_to,#between) must fail when the validator is exclusive (less_than/greater_than) ValidatorHelpersfilter errors witherror.options[:validator_type] == (kwargs[:validator] || validator_sym)— keep the parentheses;==binds tighter than||- Do not reintroduce a Minitest suite for the gem; keep consumer matcher docs for both RSpec and Minitest/shoulda
- Matcher
stub_methoduses a singleton-method wrap (notMinitest::Mock/Object#stub). Minitest 6 extracted mock tominitest-mock; do not reintroduce that dependency - Do not add
ActiveStorageValidations::Attachable(or other short names that apps use as concerns). The gem module is included into Active Record, soinclude Attachablein an app model under this namespace would resolve to the gem constant. Use theASV*prefix (spec/global/active_storage_validations_spec.rb) - Do not
File.opendummy fixtures and leave the FD for GC. Useopen_fixture/register_fixture_io(FixtureFiles); do not reintroduce anObjectSpacesweep or a raisedNOFILErlimit to mask leaks
README.md(Contributing section) anddocs/upgrade_to_4.md.cursor/rules/git.mdc(commit / PR title format)lib/active_storage_validations.rb+railtie.rb- A simple validator:
attached_validator.rb - Comparison path:
base_comparison_validator.rb+size_validator.rb - Shared core:
asv_attachable_adapter.rb,shared/asv_attachable.rb,shared/asv_analyzable.rb,shared/asv_errorable.rb - Spoofing / sniffers:
content_type_validator.rb+analyzer/content_type_analyzer/ spec/rails_helper.rb+ one validator spec and itsshared_examples/.github/workflows/main.yml