Module: Karst::Access::PopulationApprovals

Defined in:
lib/karst/access/population_approvals.rb

Overview

The local, machine-scoped record of which discovered candidate populations a developer has explicitly allowed Karst to execute automatically (see Karst::Access::ApprovedPopulations for how an approval is turned back into something runnable, and Karst::Access::PopulationDiscovery for what can be discovered at all).

Deliberately data, never code: an entry is a model name and a scope name, and nothing else. Karst never writes a lambda, a snippet, or any other executable Ruby into this file, and never evaluates its contents -- an entry is only ever compared, as a string, against what current source-based discovery independently confirms. That is what keeps this file from degrading into an arbitrary method allowlist: adding {"model": "User", "scope": "destroy_all"} by hand approves nothing, because discovery will not confirm it.

Stored under the host application's tmp/ (tmp/karst/) on purpose: Rails already treats tmp/ as machine-local, disposable, and git-ignored, which is exactly the intended lifetime of a local development approval. Deleting the file resets every approval; nothing else in Karst is affected. Karst never edits the host application's initializer or any other committed file.

Every read fails closed. A file that is unreadable, is not JSON, is not the expected document shape, carries an unknown schema version, or holds a single unusable entry approves nothing at all and reports an error for the panel to show -- rather than partially trusting a document Karst cannot fully account for. rubocop:disable Metrics/ModuleLength

Constant Summary collapse

SCHEMA_VERSION =
1
RELATIVE_PATH =
File.join("tmp", "karst", "approved_populations.json")
MODEL_NAME =

Both names are matched against exactly what PopulationDiscovery can produce -- a real constant path, and a scope name Ripper read from a literal symbol/string in scope :name, -> { ... }. Anything else is rejected before it can even be compared to a discovered candidate.

/\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
SCOPE_NAME =
/\A[a-z_][A-Za-z0-9_]*\z/
MAX_ENTRIES =

A bound on how much of this file Karst will consider at all, so a corrupted or maliciously grown document cannot turn every principal source resolution into an unbounded amount of source parsing.

500
Entry =
Value.define(:model_name, :method_name) do
  def matches?(model_name, method_name)
    self.model_name == model_name.to_s && self.method_name == method_name.to_s
  end

  def display_label
    "#{model_name}.#{method_name}"
  end
end
Record =

entries is always usable (possibly empty) and always sorted; error is a human-readable reason the stored document was rejected or could not be written, or nil.

Value.define(:entries, :error) do
  def approved?(model_name, method_name)
    entries.any? { |entry| entry.matches?(model_name, method_name) }
  end
end

Class Method Summary collapse

Class Method Details

.display_pathObject

The path as a developer should see it: relative to the application root, since that is where they will go looking for (or delete) it.



80
81
82
# File 'lib/karst/access/population_approvals.rb', line 80

def display_path
  RELATIVE_PATH
end

.loadObject



84
85
86
87
88
89
90
91
92
93
# File 'lib/karst/access/population_approvals.rb', line 84

def load
  document = JSON.parse(File.read(path))
  parse(document)
rescue Errno::ENOENT
  empty
rescue JSON::ParserError
  failed("could not be read as JSON")
rescue StandardError => e
  failed("could not be read (#{e.class})")
end

.pathObject



74
75
76
# File 'lib/karst/access/population_approvals.rb', line 74

def path
  File.join(root, RELATIVE_PATH)
end

.replace(entries) ⇒ Object

Replaces the whole approval set with entries, atomically: callers always submit the complete list they intend to keep, so unapproving is simply approving a smaller set, and a partially written file can never be observed.



99
100
101
102
103
104
105
# File 'lib/karst/access/population_approvals.rb', line 99

def replace(entries)
  normalized = normalize(entries)
  write(normalized)
  Record.new(entries: normalized, error: nil)
rescue StandardError => e
  Record.new(entries: normalized || [].freeze, error: "approvals could not be saved (#{e.class})")
end