petticoat 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 697674762c68bca9da5ca1d6a9a11d8293aa5b5b7acbee009d949d49180efea0
4
+ data.tar.gz: 6990ad9ceda48183c0f29476e6d01e31d3352ac09925f9f5c6ea03100dfd9021
5
+ SHA512:
6
+ metadata.gz: a12b5b6a52860b015aa2bc4722b5d84156ef98bf413918154c71fb2e4ff31a3e8805e8307ba74f2223a3516a610dabc75b3b2427fc02588a1d7303a311c8eb4d
7
+ data.tar.gz: c901e51344bd319253ff4bd61e0b13b9c40ff00afe97b7481c01581aa00c083b3c61c2e48eb6f56bf77914be34a8871a1c0557034fc501a813406ba80e2b79ae
data/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # Petticoat
2
+
3
+ Petticoat turns existing CanCanCan abilities into a small, deterministic catalogue
4
+ for frontend controls and navigation. It does not authorize requests. The server
5
+ must still check every operation.
6
+
7
+ The core is application-independent. It does not load Rails, build users,
8
+ discover abilities, query a database or run permission blocks.
9
+
10
+ ## Supported environment
11
+
12
+ Version 0.2.0 is tested with Ruby 4.0.6 and this CanCanCan 3.6.0 fork:
13
+ `olistik/cancancan` at `18ce8ad800658629d4c1c2cb1382cd4794c2a5a3`.
14
+ The adapter checks the relevant source files as well as the version number.
15
+ Upstream CanCanCan 3.6.0 and other builds are **not verified** and may be rejected.
16
+ Do not bypass the check to make an incompatible build appear supported.
17
+
18
+ The development Gemfile pins that fork. Applications must select it themselves;
19
+ a gem dependency cannot select another gem's Git source.
20
+
21
+ ## One selected population per document
22
+
23
+ Callers supply completed ability objects and meaningful subject identifiers.
24
+ Keep users with different construction contexts separate. For example, compile
25
+ privileged and ordinary users into separate documents when a flag changes rule
26
+ construction. Petticoat does not know what your application's flags mean.
27
+
28
+ Here is a complete, database-free example:
29
+
30
+ ```ruby
31
+ require 'petticoat'
32
+
33
+ Item = Struct.new(:owner)
34
+ ability = Class.new { include CanCan::Ability }.new
35
+ ability.can :read, Item, owner: 42
36
+
37
+ document = Petticoat::Document.new(subjects: { 'items' => Item })
38
+ document.add(context: 'editor', role: 'member', scenario: 'ordinary',
39
+ snapshot: Petticoat::Snapshot.new(ability))
40
+
41
+ catalogue = document.to_h
42
+ Petticoat::Schema.validate!(catalogue)
43
+ puts Petticoat::Document.lookup(catalogue, context: 'editor',
44
+ role: 'member', subject: 'items')
45
+ # possible
46
+ ```
47
+
48
+ The owner value is not exported. A conditional grant does not promise that an
49
+ accessible persisted record exists.
50
+
51
+ The resource context looks like:
52
+
53
+ ```json
54
+ {
55
+ "scope": "individual",
56
+ "examined_roles": ["member"],
57
+ "resources": {
58
+ "items": {"possible": ["member"], "unknown": []}
59
+ }
60
+ }
61
+ ```
62
+
63
+ A document's `subjects` is an array of opaque identifiers, not JSON:API metadata.
64
+ Do not infer identifiers by pluralizing model names. The application owns that
65
+ mapping and any application-specific envelope.
66
+
67
+ ## Reading answers
68
+
69
+ The default `view: resources` answers whether some interaction may be available:
70
+
71
+ - `possible`: every observed setup has some potentially permitted action.
72
+ - `denied`: all observed setups deny all actions in this scope.
73
+ - `unknown`: a failed capture, unsupported behavior, disagreement or missing
74
+ coverage prevents a conclusion.
75
+
76
+ Resource entries contain sorted, disjoint `possible` and `unknown` role lists.
77
+ An examined role absent from both lists defaults to denied. A wholly denied
78
+ resource is omitted. An unexamined role, missing context or unregistered subject
79
+ is unknown, not denied. `examined_roles` includes failed attempts; examined does
80
+ not mean successful.
81
+
82
+ For action-specific controls:
83
+
84
+ ```ruby
85
+ actions = document.to_h(view: 'actions')
86
+ Petticoat::Document.lookup(actions, context: 'editor', role: 'member',
87
+ subject: 'items', action: 'read')
88
+ # conditional
89
+ ```
90
+
91
+ The action view uses `contexts[context].roles[role][subject][action]` and returns
92
+ `unconditional`, `conditional`, `denied` or `unknown`. Read an explicit action
93
+ first, then its action `*`, then the scoped denied default. Explicit exceptions
94
+ win. Aliases are already expanded directionally: index does not imply literal
95
+ read. Custom actions keep their names. A registered subject `*` represents
96
+ CanCanCan's `:all`; it is not a fallback for unknown subjects.
97
+
98
+ Resource queries omit action; action queries require it. Unknown versions, missing
99
+ dimensions and extra query dimensions (including admin or scenario) return unknown.
100
+ Use separate documents to select a population; lookup does not guess or merge one.
101
+
102
+ Within each setup, any supported unconditional or conditional action makes the
103
+ resource possible. Otherwise an unknown action makes it unknown; otherwise denied.
104
+ Only then are resource answers compared across setups with the same role/context.
105
+ Two setups may allow different actions yet agree that some interaction is possible.
106
+ This is not evidence that listing, reading, or any particular endpoint will work.
107
+
108
+ ## Capturing and validating
109
+
110
+ `Document#add(context:, role:, scenario:, snapshot:, scope: 'individual')`
111
+ records a captured setup. Use `scope: 'composed'` for an actual ordered ability
112
+ composition built by the caller. Never manufacture a global union of abilities.
113
+ Duplicate scenario identities within a context and conflicting scope raise errors.
114
+
115
+ A caller that has established a failed or unsupported setup may add
116
+ `snapshot: nil`; it remains unknown for every registered subject. Unexpected
117
+ execution failures should abort the application's export, preserving valid output.
118
+
119
+ `Document#evidence` returns sorted, redacted per-scenario action observations.
120
+ `Snapshot#evidence(identifier_callable)` returns redacted rule evidence in its
121
+ original order. Neither exports condition values, SQL or record inspect strings.
122
+ Condition hashes, ordinary blocks and attribute restrictions remain conditional
123
+ as justified by rule order. SQL, instance subjects and custom matching can be unknown.
124
+ Stored blocks are never executed merely to export them.
125
+
126
+ `Schema.validate!` checks the [generic schema](lib/petticoat/schema.json), related
127
+ membership/subject coverage and the content digest. Invalid data raises an error.
128
+ `Schema.validate_contexts!(contexts, subjects:, view:)` shares cross-field checks
129
+ with applications that have already schema-validated their own envelope.
130
+
131
+ `Petticoat.canonical_json(value)` sorts maps, not ordered rule arrays.
132
+ Document digests exclude themselves. The caller owns source fingerprints, output
133
+ paths, safe file replacement and freshness checks. Keep private diagnostics
134
+ separate from frontend data.
135
+
136
+ ## Install the optional CLI
137
+
138
+ The core above works without the CLI. `require 'petticoat'` does not load the
139
+ installer or an application. Bundler exposes the gem's `exe/petticoat` as
140
+ `bundle exec petticoat`.
141
+
142
+ Add Petticoat to your development/test bundle with `require: false`. Select the
143
+ [supported CanCanCan source](#supported-environment) and install the bundle first.
144
+ From the application root, run:
145
+
146
+ ```sh
147
+ BUNDLE_FROZEN=true bundle exec petticoat install --dry-run
148
+ BUNDLE_FROZEN=true bundle exec petticoat install
149
+ ```
150
+
151
+ This creates just two application-owned files:
152
+
153
+ - `bin/petticoat`: a thin, executable Bundler wrapper that loads the gem executable.
154
+ - `config/petticoat.rb`: a commented integration entry to connect your own runner.
155
+
156
+ The starter is deliberately **not ready to export**. Until you configure the
157
+ runner, `export`, `check` and `test` fail with an actionable error and create no
158
+ catalogue. The installer does not infer roles, copy policies, configure databases,
159
+ select a test framework, run factories or boot your app. It never edits Gemfile,
160
+ the lockfile or existing application files.
161
+
162
+ Missing files are created; identical bytes and modes are left alone. Any content
163
+ or mode conflict, symlink or unsafe path aborts before writes. Dry-run writes
164
+ nothing. There is no `--preset`, `--force` or upgrade mode. Caught publication
165
+ failures roll back only unchanged files created by that attempt. Installation is
166
+ exclusive, not crash-atomic; rerun to finish an identical partial installation.
167
+
168
+ Freeze the bundle after dependency setup: otherwise Bundler itself can touch the
169
+ lockfile before a dry run reaches the installer. The wrapper and executable also
170
+ freeze their own setup.
171
+
172
+ ## Connect your application
173
+
174
+ Edit `config/petticoat.rb`: require your application-owned runner and assign it
175
+ to `Petticoat::CLI.integration`. The generated comments show the shape. This is
176
+ trusted command configuration, not a framework initializer. Only operational
177
+ commands load it; help, version and install do not.
178
+
179
+ The runner can be a module, object or lambda that implements:
180
+
181
+ ```ruby
182
+ def self.call(mode:, arguments:, options:, out:)
183
+ # Run your application's build, comparison or tests.
184
+ # Raise on failure, including a failed child test process.
185
+ end
186
+ ```
187
+
188
+ | Keyword | What the CLI supplies |
189
+ | --- | --- |
190
+ | `mode` | `"export"`, `"check"` or `"test"`. |
191
+ | `arguments` | Unparsed application test arguments; empty for export/check. |
192
+ | `options[:output]` | Absolute path from an explicit `--output PATH`, if supplied. |
193
+ | `options[:coverage]` | Absolute path from an explicit `--diagnostics PATH`, if supplied. |
194
+ | `options[:seed]` | Integer from `--seed N`, if supplied. |
195
+ | `out` | The output IO for status messages. |
196
+
197
+ No paths, seed or test framework defaults are invented by the gem. The runner
198
+ must raise on failure; returning `false` or a nonzero number does not signal an
199
+ exit status. Normal completion means CLI exit 0. Exceptions produce exit 1;
200
+ unexpected exception messages are redacted. Use `Petticoat::Error` only for safe,
201
+ non-sensitive user-facing messages.
202
+
203
+ Your runner owns the parts that depend on your application:
204
+
205
+ 1. Establish isolated test resources and disable live calls/delivery **before**
206
+ booting a framework or constructing factory records. A test environment name
207
+ alone does not prove a database is safe.
208
+ 2. Build real, asserted scenarios and actual ordered ability compositions.
209
+ Supply subject identifiers and keep distinct construction populations separate.
210
+ Pass completed abilities to `Snapshot` and `Document`; do not copy permissions.
211
+ 3. Clean up scenario effects. Preserve known gaps as unknown; abort unexpected
212
+ failures. Validate the complete catalogue before safely replacing any output.
213
+ 4. Implement `check` as a non-writing comparison, and `test` using your own test
214
+ framework and chosen scope. Keep coverage diagnostics out of frontend assets.
215
+
216
+ After implementing and testing that boundary:
217
+
218
+ ```sh
219
+ bin/petticoat test
220
+ bin/petticoat export
221
+ bin/petticoat check
222
+ ```
223
+
224
+ Export/check also accept `--output PATH`, `--diagnostics PATH` and `--seed N`.
225
+ The CLI dispatches these operations; it does not implement database isolation,
226
+ file publication or freshness for you.
227
+
228
+ ```text
229
+ Gem: bin wrapper template + CLI + capture / reduction / lookup / schema
230
+ |
231
+ loads config/petticoat.rb
232
+ |
233
+ App: explicit runner + scenarios / composition / subjects / isolation / output
234
+ |
235
+ explicit build using the core
236
+ |
237
+ Frontend: generated JSON hints Server: existing authorization checks
238
+ ```
239
+
240
+ Commit the wrapper, completed config, application runner, its tests and any
241
+ generated assets in the application repository. Keep application-specific
242
+ onboarding and safety instructions there too. A fresh application needs its own
243
+ integration; the gem cannot safely infer one.
244
+
245
+ For upgrades, update the dependency and review your runner against this contract.
246
+ Do not rerun install over a customized config. Application integrations are not
247
+ bundled presets and are never overwritten by a gem update. Ordinary Ability
248
+ changes need a fresh export, not parallel permission declarations. Add scenarios
249
+ when new roles, construction-time branches or composition contexts require them.
250
+
251
+ ## Develop and build locally
252
+
253
+ Use the Ruby version in `.ruby-version`:
254
+
255
+ ```sh
256
+ bundle install --local
257
+ bundle exec rspec
258
+ bundle exec rubocop
259
+ gem build petticoat.gemspec
260
+ ```
261
+
262
+ If a locked dependency is not installed locally, dependency setup may need network
263
+ access. Tests use real CanCanCan and synthetic subjects, not Rails or factories.
264
+ They also execute the Ruby example above and test the optional CLI and installer
265
+ against filesystem failures and an application-supplied callable in a fresh process.
266
+ Application-level factory, isolation and publication tests belong to each application.
267
+
268
+ This is a pre-release, Git-sourced gem, not a published RubyGems release.
269
+ No open-source license or public release is provided; `Nonstandard`
270
+ metadata does not grant redistribution rights. Publication is deliberately disabled
271
+ through an invalid `allowed_push_host`. Choose licensing, verify the name, and
272
+ authorize distribution separately before any release.
273
+
274
+ Made with ❤️ by [olistik](https://olisti.co)
data/exe/petticoat ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ ENV['BUNDLE_FROZEN'] = 'true'
5
+ require 'bundler/setup'
6
+ require 'petticoat/cli'
7
+
8
+ exit Petticoat::CLI.run(ARGV)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petticoat
4
+ # All access to undocumented CanCanCan internals lives here. Only private
5
+ # copies receive the gem's relevance/alias-cache mutations.
6
+ class Adapter
7
+ ID = 'cancancan-3.6.0-18ce8ad80065'
8
+ FILES = {
9
+ 'ability.rb' => '6864743679252c07b750630ca4ebbad94117d6367ef6c33d7132a0e1ce2d5bbc',
10
+ 'ability/rules.rb' => '4a7f96dd94b606e904718642b1a0a97efa3000be6396fb5f1150be005b02032b',
11
+ 'ability/actions.rb' => '97c79d145b0e694cb273c4b7f3915426ead55546295d1ba92d6bb0cdfe08c3f7',
12
+ 'rule.rb' => '7a4b0ce56f001a29a66c1051928732a88c893060ed1e11510fa2e1d3c31e7d18',
13
+ 'relevant.rb' => 'dbe8ea801fd50a2e6fbb87a65825febb6affdee367c6a52706340bf1a91e55a1',
14
+ 'conditions_matcher.rb' => 'f324a2a78788241c6ea53aecd3b9f48b947d42551db3eba84e6f9226c33351b7',
15
+ 'class_matcher.rb' => 'b26f464494c2ff1551c4fa004d28fc4c82cad4cacbb04088e55971d47af489e2'
16
+ }.freeze
17
+
18
+ Shell = Class.new { include CanCan::Ability }
19
+ COPIED_FIELDS = %i[actions subjects attributes].freeze
20
+ METHOD_OWNERS = {
21
+ can?: CanCan::Ability,
22
+ alternative_subjects: CanCan::Ability,
23
+ rules: CanCan::Ability::Rules,
24
+ relevant_rules: CanCan::Ability::Rules,
25
+ possible_relevant_rules: CanCan::Ability::Rules,
26
+ expand_actions: CanCan::Ability::Actions,
27
+ aliased_actions: CanCan::Ability::Actions
28
+ }.freeze
29
+
30
+ attr_reader :rules, :aliases, :issues
31
+
32
+ def self.verify!
33
+ raise Error, 'Unsupported CanCanCan version' unless CanCan::VERSION == '3.6.0'
34
+ return if @verified
35
+
36
+ root = File.join(Gem.loaded_specs.fetch('cancancan').full_gem_path, 'lib/cancan')
37
+ unless FILES.all? { |file, digest| Digest::SHA256.file(File.join(root, file)).hexdigest == digest }
38
+ raise Error, 'Unsupported CanCanCan source; review the manifest adapter before exporting'
39
+ end
40
+
41
+ @verified = true
42
+ end
43
+
44
+ def initialize(ability)
45
+ self.class.verify!
46
+ @shell = Shell.new
47
+ @issues = semantic_issues(ability)
48
+ @aliases = copy_aliases(ability)
49
+ validate_aliases!
50
+ @shell.instance_variable_set(:@aliased_actions, @aliases.transform_values(&:dup))
51
+ @rules = copy_rules(ability)
52
+ cache = ability.instance_variable_get(:@expanded_actions) || {}
53
+ @shell.instance_variable_set(:@expanded_actions, cache.to_h { |key, value| [key.dup, value.dup] })
54
+ end
55
+
56
+ def relevant(action, subject)
57
+ @shell.send(:relevant_rules, action, subject)
58
+ end
59
+
60
+ def actions
61
+ (rules.flat_map(&:actions) + aliases.keys + aliases.values.flatten).uniq
62
+ end
63
+
64
+ def unsupported_subjects?
65
+ rules.any? do |rule|
66
+ rule.subjects.empty? || rule.subjects.any? { |subject| !subject.is_a?(Module) && !subject.is_a?(Symbol) }
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def copy_aliases(ability)
73
+ source = ability.instance_variable_get(:@aliased_actions) || @shell.aliased_actions
74
+ source.to_h { |key, values| [key, values.dup] }
75
+ end
76
+
77
+ def semantic_issues(ability)
78
+ METHOD_OWNERS.filter_map { |name, owner| "custom_#{name}" unless ability.method(name).owner == owner }
79
+ end
80
+
81
+ def copy_rules(ability)
82
+ (ability.instance_variable_get(:@rules) || []).map do |rule|
83
+ copy = rule.dup
84
+ COPIED_FIELDS.each do |field|
85
+ copy.instance_variable_set(:"@#{field}", rule.public_send(field).dup)
86
+ end
87
+ @shell.send(:add_rule, copy)
88
+ copy
89
+ end
90
+ end
91
+
92
+ def validate_aliases!
93
+ @aliases.each_key { |action| visit_alias(action, []) }
94
+ end
95
+
96
+ def visit_alias(action, path)
97
+ raise Error, 'Unsupported alias cycle' if path.include?(action)
98
+ raise Error, 'Unsupported action identifier' unless action.is_a?(Symbol)
99
+
100
+ @aliases.fetch(action, []).each { |child| visit_alias(child, path + [action]) }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative '../petticoat'
5
+
6
+ module Petticoat
7
+ # Optional command entry. Loading the core never loads an application's setup.
8
+ class CLI
9
+ HELP = <<~TEXT
10
+ Usage: petticoat install [--dry-run]
11
+ petticoat export|check [--output PATH] [--diagnostics PATH] [--seed N]
12
+ petticoat test [application test arguments]
13
+ petticoat --help|--version
14
+
15
+ Run from the application root. Installation creates missing files only.
16
+ Export, check and test use your runner in config/petticoat.rb.
17
+ See the Petticoat README for the integration contract.
18
+ TEXT
19
+
20
+ class << self
21
+ attr_accessor :integration
22
+
23
+ def run(arguments, root: Dir.pwd, out: $stdout, err: $stderr)
24
+ self.integration = nil
25
+ new(arguments.dup, root, out).run
26
+ rescue OptionParser::ParseError
27
+ err.puts 'Petticoat: invalid command options; run petticoat --help.'
28
+ 1
29
+ rescue StandardError => e
30
+ # Only our own explicit messages may reach the terminal.
31
+ message = e.is_a?(Petticoat::Error) ||
32
+ (e.instance_of?(RuntimeError) && e.message.start_with?('Petticoat'))
33
+ err.puts(message ? e.message : "Petticoat command failed (#{e.class}).")
34
+ 1
35
+ ensure
36
+ self.integration = nil
37
+ end
38
+ end
39
+
40
+ def initialize(arguments, root, out)
41
+ @arguments = arguments
42
+ @root = File.expand_path(root)
43
+ @out = out
44
+ end
45
+
46
+ def run
47
+ mode = @arguments.shift
48
+ case mode
49
+ when nil, '--help', '-h' then display(HELP)
50
+ when '--version', '-v' then display(Petticoat::VERSION)
51
+ when 'install' then install
52
+ when 'export', 'check', 'test' then operate(mode)
53
+ else raise Error, 'Petticoat: unknown command; run petticoat --help.'
54
+ end
55
+ 0
56
+ end
57
+
58
+ private
59
+
60
+ def display(message)
61
+ reject_arguments!
62
+ @out.puts message
63
+ end
64
+
65
+ def install
66
+ require_relative 'installer'
67
+ options = { dry_run: false }
68
+ parser = OptionParser.new do |item|
69
+ item.on('--dry-run') { options[:dry_run] = true }
70
+ end
71
+ parser.parse!(@arguments)
72
+ reject_arguments!
73
+ Installer.new(root: @root).call(dry_run: options[:dry_run], out: @out)
74
+ end
75
+
76
+ def operate(mode)
77
+ options = mode == 'test' ? {} : export_options
78
+ config = File.join(@root, 'config/petticoat.rb')
79
+ unless File.file?(config)
80
+ raise Error, 'Petticoat: missing config/petticoat.rb; run petticoat install, ' \
81
+ 'then configure your application runner.'
82
+ end
83
+
84
+ load config
85
+ unless self.class.integration.respond_to?(:call)
86
+ raise Error, 'Petticoat: config/petticoat.rb must select a callable CLI.integration.'
87
+ end
88
+
89
+ self.class.integration.call(mode: mode, arguments: @arguments, options: options, out: @out)
90
+ end
91
+
92
+ def export_options
93
+ options = {}
94
+ OptionParser.new do |parser|
95
+ parser.on('--output PATH') { |value| options[:output] = File.expand_path(value, @root) }
96
+ parser.on('--diagnostics PATH') { |value| options[:coverage] = File.expand_path(value, @root) }
97
+ parser.on('--seed N', Integer) { |value| options[:seed] = value }
98
+ end.parse!(@arguments)
99
+ reject_arguments!
100
+ options
101
+ end
102
+
103
+ def reject_arguments!
104
+ raise Error, 'Petticoat: unexpected command arguments; run petticoat --help.' unless @arguments.empty?
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petticoat
4
+ class Document
5
+ SCHEMA_VERSION = 1
6
+ VIEWS = %w[resources actions].freeze
7
+ DEFAULT_ACTIONS = %w[read index show create new update edit destroy manage].freeze
8
+ DEFAULTS = { 'state' => 'denied', 'scope' => 'registered_subjects_in_present_entries' }.freeze
9
+ private_constant :DEFAULTS
10
+
11
+ def initialize(subjects:, actions: DEFAULT_ACTIONS)
12
+ @subjects = subjects
13
+ @actions = actions
14
+ @observations = {}
15
+ end
16
+
17
+ # One document represents one caller-selected construction population.
18
+ def add(context:, role:, scenario:, snapshot:, scope: 'individual')
19
+ entry = (@observations[context] ||= { scope:, snapshots: {} })
20
+ raise Error, 'Conflicting context scope' unless entry[:scope] == scope
21
+ raise Error, 'Duplicate scenario in context' if entry[:snapshots].key?(scenario)
22
+
23
+ entry[:snapshots][scenario] = { role:, snapshot: }
24
+ end
25
+
26
+ def to_h(view: 'resources')
27
+ raise Error, 'Unsupported catalogue view' unless VIEWS.include?(view)
28
+
29
+ actions = all_actions
30
+ payload = {
31
+ 'schema_version' => SCHEMA_VERSION,
32
+ 'view' => view,
33
+ 'adapter' => Adapter::ID,
34
+ 'scope' => 'observed_ability_construction',
35
+ 'basis' => 'observed_scenarios',
36
+ 'defaults' => DEFAULTS,
37
+ 'subjects' => @subjects.keys.sort,
38
+ 'contexts' => @observations.transform_values do |entry|
39
+ context_payload(entry[:scope], entry[:snapshots], actions, view)
40
+ end
41
+ }
42
+ payload['digest'] = Digest::SHA256.hexdigest(Petticoat.canonical_json(payload))
43
+ Petticoat.canonical(payload)
44
+ end
45
+
46
+ # Developer evidence, not another frontend catalogue. Values stay redacted.
47
+ def evidence
48
+ actions = all_actions
49
+ observations =
50
+ @observations.flat_map do |context, entry|
51
+ entry[:snapshots].map do |scenario, observation|
52
+ payload = snapshot_payload(observation, actions)
53
+ payload.merge('context' => context,
54
+ 'scope' => entry[:scope],
55
+ 'scenario' => scenario,
56
+ 'states' => sparse_states(payload.fetch('states')))
57
+ end
58
+ end
59
+ Petticoat.canonical(observations.sort_by { |item| item.values_at('context', 'scenario') })
60
+ end
61
+
62
+ def self.lookup(payload, **query)
63
+ return 'unknown' unless payload.is_a?(Hash)
64
+ return 'unknown' unless compatible_query?(payload, query)
65
+ return 'unknown' unless registered_subject?(payload, query.fetch(:subject))
66
+
67
+ context = payload['contexts']&.dig(query.fetch(:context))
68
+ return 'unknown' unless context.is_a?(Hash)
69
+
70
+ payload.fetch('view') == 'resources' ? lookup_resource(context, query) : lookup_actions(context, query)
71
+ end
72
+
73
+ def self.lookup_actions(context, query)
74
+ entry = context.dig('roles', query.fetch(:role))
75
+ return 'unknown' unless entry.is_a?(Hash)
76
+
77
+ actions = entry.fetch(query.fetch(:subject), {})
78
+ actions.fetch(query.fetch(:action), actions.fetch('*', DEFAULTS.fetch('state')))
79
+ end
80
+ private_class_method :lookup_actions
81
+
82
+ def self.registered_subject?(payload, subject)
83
+ payload['subjects'].is_a?(Array) && payload['subjects'].include?(subject)
84
+ end
85
+ private_class_method :registered_subject?
86
+
87
+ def self.compatible_query?(payload, query)
88
+ payload.values_at('schema_version', 'basis', 'defaults') ==
89
+ [SCHEMA_VERSION, 'observed_scenarios', DEFAULTS] &&
90
+ (query.keys - %i[context role subject action]).empty? &&
91
+ %i[context role subject].all? { |key| query[key].is_a?(String) } && compatible_view_query?(payload, query)
92
+ end
93
+ private_class_method :compatible_query?
94
+
95
+ def self.compatible_view_query?(payload, query)
96
+ case payload['view']
97
+ when 'resources' then !query.key?(:action)
98
+ when 'actions' then query[:action].is_a?(String)
99
+ else false
100
+ end
101
+ end
102
+ private_class_method :compatible_view_query?
103
+
104
+ def self.lookup_resource(context, query)
105
+ role = query.fetch(:role)
106
+ examined = context['examined_roles']
107
+ resources = context['resources']
108
+ return 'unknown' unless examined.is_a?(Array) && examined.include?(role) && resources.is_a?(Hash)
109
+ return DEFAULTS.fetch('state') unless resources.key?(query.fetch(:subject))
110
+
111
+ resource_membership(resources.fetch(query.fetch(:subject)), role)
112
+ end
113
+ private_class_method :lookup_resource
114
+
115
+ def self.resource_membership(entry, role)
116
+ return 'unknown' unless entry.is_a?(Hash) && entry['possible'].is_a?(Array) && entry['unknown'].is_a?(Array)
117
+ return 'unknown' if entry.fetch('unknown').include?(role)
118
+ return 'possible' if entry.fetch('possible').include?(role)
119
+
120
+ DEFAULTS.fetch('state')
121
+ end
122
+ private_class_method :resource_membership
123
+
124
+ private
125
+
126
+ def all_actions
127
+ snapshots = @observations.values.flat_map { |context| context[:snapshots].values }
128
+ observed = snapshots.flat_map { |entry| entry[:snapshot]&.actions || [] }
129
+ (@actions + observed + ['*']).uniq.sort
130
+ end
131
+
132
+ def context_payload(scope, observations, actions, view)
133
+ snapshots = observations.values.map { |observation| snapshot_payload(observation, actions) }
134
+ roles =
135
+ snapshots.group_by { |snapshot| snapshot.fetch('role') }
136
+ .transform_values do |group|
137
+ view == 'resources' ? resource_states(group) : sparse_states(aggregate(group, actions))
138
+ end
139
+ # Agreement uses complete tables. Only the exported representation is sparse.
140
+ view == 'resources' ? resource_context(scope, roles) : { 'scope' => scope, 'roles' => roles }
141
+ end
142
+
143
+ # Invert already-agreed states, without losing examined-but-denied roles.
144
+ def resource_context(scope, roles)
145
+ resources = {}
146
+ roles.sort.each do |role, states|
147
+ states.each do |subject, state|
148
+ entry = (resources[subject] ||= { 'possible' => [], 'unknown' => [] })
149
+ entry.fetch(state) << role
150
+ end
151
+ end
152
+ { 'scope' => scope, 'examined_roles' => roles.keys.sort, 'resources' => resources }
153
+ end
154
+
155
+ def snapshot_payload(observation, actions)
156
+ snapshot = observation[:snapshot]
157
+ {
158
+ 'role' => observation[:role],
159
+ 'coverage' => snapshot ? 'observed' : 'unknown',
160
+ 'aliases' => snapshot&.aliases || {},
161
+ 'states' => states(snapshot, actions)
162
+ }
163
+ end
164
+
165
+ # Ask whether any interaction is possible within EACH scenario first. Two
166
+ # scenarios may allow different actions while agreeing at resource level.
167
+ def resource_states(observations)
168
+ @subjects.filter_map do |identifier, _subject|
169
+ values = observations.map { |snapshot| interaction(snapshot.fetch('states').fetch(identifier)) }
170
+ .uniq
171
+ value = values.size == 1 ? values.first : 'unknown'
172
+ [identifier, value] unless value == DEFAULTS.fetch('state')
173
+ end.to_h
174
+ end
175
+
176
+ def interaction(actions)
177
+ return 'possible' if actions.values.any? { |state| %w[unconditional conditional].include?(state) }
178
+ return 'unknown' if actions.value?('unknown')
179
+
180
+ 'denied'
181
+ end
182
+
183
+ def states(snapshot, actions)
184
+ @subjects.to_h do |identifier, subject|
185
+ values = actions.to_h { |action| [action, snapshot ? snapshot.state(action, subject) : 'unknown'] }
186
+ [identifier, compact(values)]
187
+ end
188
+ end
189
+
190
+ def aggregate(observations, actions)
191
+ @subjects.to_h do |identifier, _subject|
192
+ values = actions.to_h { |action| [action, agreement(observations, identifier, action)] }
193
+ [identifier, compact(values)]
194
+ end
195
+ end
196
+
197
+ def agreement(observations, identifier, action)
198
+ values = observations.map do |snapshot|
199
+ table = snapshot.fetch('states').fetch(identifier)
200
+ table.fetch(action, table.fetch('*'))
201
+ end.uniq
202
+ values.size == 1 ? values.first : 'unknown'
203
+ end
204
+
205
+ def compact(values)
206
+ values.select { |action, value| action == '*' || value != values.fetch('*') }
207
+ end
208
+
209
+ def sparse_states(states)
210
+ states.filter_map do |subject, actions|
211
+ sparse = actions.reject { |action, value| action == '*' && value == DEFAULTS.fetch('state') }
212
+ [subject, sparse] unless sparse.empty?
213
+ end.to_h
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petticoat
4
+ # Reject symlinks and non-directory parents inside the selected checkout.
5
+ class InstallationPath
6
+ def initialize(root, relative)
7
+ parts = relative.split('/')
8
+ if relative.empty? || relative.start_with?('/') || parts.any? { |part| ['', '.', '..'].include?(part) } ||
9
+ relative.include?("\0") || relative.include?('\\')
10
+ raise Error, 'Petticoat: unsafe installation path.'
11
+ end
12
+
13
+ @root = File.realpath(root)
14
+ @parts = parts
15
+ @relative = relative
16
+ end
17
+
18
+ def checked
19
+ path = @root
20
+ @parts.each_with_index do |part, index|
21
+ path = File.join(path, part)
22
+ invalid = File.symlink?(path) ||
23
+ (File.exist?(path) && (index == @parts.size - 1 ? !File.file?(path) : !File.directory?(path)))
24
+ raise Error, "Petticoat: unsafe installation path: #{@relative}." if invalid
25
+ end
26
+ path
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require_relative '../petticoat'
6
+ require_relative 'installation_path'
7
+
8
+ module Petticoat
9
+ # Create-only publication: a preflight conflict never causes partial installation.
10
+ class Installer
11
+ FILES = { 'bin/petticoat' => 0o755, 'config/petticoat.rb' => 0o644 }.freeze
12
+
13
+ def initialize(root:)
14
+ @root = File.realpath(root)
15
+ end
16
+
17
+ def call(dry_run: false, out: $stdout)
18
+ entries = plan
19
+ entries.each { |entry| out.puts "#{entry.fetch(:state)} #{entry.fetch(:relative)}" }
20
+ additions = entries.select { |entry| entry.fetch(:state) == 'create' }
21
+ publish(additions) unless dry_run || additions.empty?
22
+ out.puts(dry_run ? 'Petticoat dry run: no files written.' : 'Petticoat starter installed.')
23
+ out.puts 'Next: edit config/petticoat.rb.' unless dry_run
24
+ out.puts 'See the Petticoat README. Install does not configure or start your app, or generate catalogues.'
25
+ entries
26
+ end
27
+
28
+ private
29
+
30
+ def files
31
+ directory = File.expand_path('../../templates', __dir__)
32
+ FILES.to_h do |path, mode|
33
+ source = InstallationPath.new(directory, path).checked
34
+ [path, { bytes: File.binread(source), mode: mode }]
35
+ end
36
+ end
37
+
38
+ def plan
39
+ entries = files.sort.map { |relative, file| entry(relative, file) }
40
+ conflicts = entries.select { |item| item.fetch(:state) == 'conflict' }
41
+ unless conflicts.empty?
42
+ names = conflicts.map { |item| item.fetch(:relative) }.join(', ')
43
+ raise Error, "Petticoat installation conflicts: #{names}. " \
44
+ 'No files written. Review existing integration files; there is no --force.'
45
+ end
46
+ entries
47
+ end
48
+
49
+ def entry(relative, file)
50
+ raise Error, 'Petticoat: target is outside the two-file starter.' unless FILES.key?(relative)
51
+
52
+ path = InstallationPath.new(@root, relative).checked
53
+ state = if !File.exist?(path)
54
+ 'create'
55
+ elsif identical?(path, file)
56
+ 'unchanged'
57
+ else
58
+ 'conflict'
59
+ end
60
+ file.merge(relative: relative, path: path, state: state)
61
+ end
62
+
63
+ def identical?(path, file)
64
+ File.binread(path) == file.fetch(:bytes) && (File.stat(path).mode & 0o777) == file.fetch(:mode)
65
+ end
66
+
67
+ def publish(entries)
68
+ staged = []
69
+ installed = []
70
+ directories = []
71
+ begin
72
+ entries.each { |entry| staged << stage(entry) }
73
+ staged.each { |entry| install_file(entry, installed, directories) }
74
+ rescue StandardError
75
+ rollback(installed, directories)
76
+ raise
77
+ ensure
78
+ staged.each { |entry| entry.fetch(:temporary).close! }
79
+ end
80
+ end
81
+
82
+ def stage(entry)
83
+ file = Tempfile.new('.petticoat-install-', @root)
84
+ file.binmode
85
+ file.write(entry.fetch(:bytes))
86
+ file.flush
87
+ file.fsync
88
+ file.chmod(entry.fetch(:mode))
89
+ entry.merge(temporary: file)
90
+ rescue StandardError
91
+ file&.close!
92
+ raise
93
+ end
94
+
95
+ def install_file(entry, installed, directories)
96
+ InstallationPath.new(@root, entry.fetch(:relative)).checked
97
+ make_parents(File.dirname(entry.fetch(:path)), directories)
98
+ # Exclusive creation, including when another writer races the preflight.
99
+ File.link(entry.fetch(:temporary).path, entry.fetch(:path))
100
+ installed << entry.merge(identity: File.stat(entry.fetch(:path)).ino)
101
+ end
102
+
103
+ def make_parents(directory, created)
104
+ return if directory == @root || File.directory?(directory)
105
+
106
+ make_parents(File.dirname(directory), created)
107
+ Dir.mkdir(directory)
108
+ created << { path: directory, identity: File.stat(directory).ino }
109
+ end
110
+
111
+ def rollback(installed, directories)
112
+ installed.reverse_each { |entry| remove_owned_file(entry) }
113
+ directories.reverse_each do |entry|
114
+ path = entry.fetch(:path)
115
+ next unless File.directory?(path) && File.realpath(path) == path &&
116
+ File.stat(path).ino == entry.fetch(:identity)
117
+
118
+ Dir.rmdir(path)
119
+ rescue SystemCallError
120
+ # Preserve nonempty or concurrently changed directories.
121
+ next
122
+ end
123
+ end
124
+
125
+ def remove_owned_file(entry)
126
+ path = InstallationPath.new(@root, entry.fetch(:relative)).checked
127
+ return unless File.file?(path) && File.stat(path).ino == entry.fetch(:identity) && identical?(path, entry)
128
+
129
+ File.unlink(path)
130
+ rescue StandardError
131
+ warn 'Petticoat: installation rollback could not remove an owned file; inspect the planned paths.'
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,198 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-04/schema#",
3
+ "title": "Petticoat observed capability catalogue",
4
+ "type": "object",
5
+ "properties": {
6
+ "schema_version": {
7
+ "enum": [
8
+ 1
9
+ ]
10
+ },
11
+ "adapter": {
12
+ "enum": [
13
+ "cancancan-3.6.0-18ce8ad80065"
14
+ ]
15
+ },
16
+ "scope": {
17
+ "enum": [
18
+ "observed_ability_construction"
19
+ ]
20
+ },
21
+ "digest": {
22
+ "$ref": "#/definitions/digest"
23
+ },
24
+ "subjects": {
25
+ "$ref": "#/definitions/strings"
26
+ },
27
+ "contexts": {
28
+ "type": "object"
29
+ },
30
+ "defaults": {
31
+ "enum": [
32
+ {
33
+ "state": "denied",
34
+ "scope": "registered_subjects_in_present_entries"
35
+ }
36
+ ]
37
+ },
38
+ "basis": {
39
+ "enum": [
40
+ "observed_scenarios"
41
+ ]
42
+ },
43
+ "view": {
44
+ "enum": [
45
+ "resources",
46
+ "actions"
47
+ ]
48
+ }
49
+ },
50
+ "required": [
51
+ "schema_version",
52
+ "adapter",
53
+ "scope",
54
+ "digest",
55
+ "subjects",
56
+ "contexts",
57
+ "defaults",
58
+ "basis",
59
+ "view"
60
+ ],
61
+ "additionalProperties": false,
62
+ "definitions": {
63
+ "digest": {
64
+ "type": "string",
65
+ "pattern": "^[a-f0-9]{64}$"
66
+ },
67
+ "strings": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "string"
71
+ },
72
+ "uniqueItems": true
73
+ },
74
+ "actions": {
75
+ "type": "object",
76
+ "additionalProperties": {
77
+ "enum": [
78
+ "unconditional",
79
+ "conditional",
80
+ "denied",
81
+ "unknown"
82
+ ]
83
+ }
84
+ },
85
+ "states": {
86
+ "type": "object",
87
+ "additionalProperties": {
88
+ "$ref": "#/definitions/actions"
89
+ }
90
+ },
91
+ "resources_contexts": {
92
+ "type": "object",
93
+ "additionalProperties": {
94
+ "$ref": "#/definitions/resources_context"
95
+ }
96
+ },
97
+ "actions_contexts": {
98
+ "type": "object",
99
+ "additionalProperties": {
100
+ "$ref": "#/definitions/actions_context"
101
+ }
102
+ },
103
+ "actions_context": {
104
+ "type": "object",
105
+ "properties": {
106
+ "scope": {
107
+ "enum": [
108
+ "individual",
109
+ "composed"
110
+ ]
111
+ },
112
+ "roles": {
113
+ "type": "object",
114
+ "additionalProperties": {
115
+ "$ref": "#/definitions/states"
116
+ }
117
+ }
118
+ },
119
+ "required": [
120
+ "scope",
121
+ "roles"
122
+ ],
123
+ "additionalProperties": false
124
+ },
125
+ "resource_roles": {
126
+ "type": "object",
127
+ "properties": {
128
+ "possible": {
129
+ "$ref": "#/definitions/strings"
130
+ },
131
+ "unknown": {
132
+ "$ref": "#/definitions/strings"
133
+ }
134
+ },
135
+ "required": [
136
+ "possible",
137
+ "unknown"
138
+ ],
139
+ "additionalProperties": false
140
+ },
141
+ "resources_context": {
142
+ "type": "object",
143
+ "properties": {
144
+ "scope": {
145
+ "enum": [
146
+ "individual",
147
+ "composed"
148
+ ]
149
+ },
150
+ "examined_roles": {
151
+ "$ref": "#/definitions/strings"
152
+ },
153
+ "resources": {
154
+ "type": "object",
155
+ "additionalProperties": {
156
+ "$ref": "#/definitions/resource_roles"
157
+ }
158
+ }
159
+ },
160
+ "required": [
161
+ "scope",
162
+ "examined_roles",
163
+ "resources"
164
+ ],
165
+ "additionalProperties": false
166
+ }
167
+ },
168
+ "allOf": [
169
+ {
170
+ "oneOf": [
171
+ {
172
+ "properties": {
173
+ "view": {
174
+ "enum": [
175
+ "resources"
176
+ ]
177
+ },
178
+ "contexts": {
179
+ "$ref": "#/definitions/resources_contexts"
180
+ }
181
+ }
182
+ },
183
+ {
184
+ "properties": {
185
+ "view": {
186
+ "enum": [
187
+ "actions"
188
+ ]
189
+ },
190
+ "contexts": {
191
+ "$ref": "#/definitions/actions_contexts"
192
+ }
193
+ }
194
+ }
195
+ ]
196
+ }
197
+ ]
198
+ }
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json-schema'
4
+
5
+ module Petticoat
6
+ module Schema
7
+ PATH = File.expand_path('schema.json', __dir__).freeze
8
+
9
+ def self.validate!(payload)
10
+ JSON::Validator.validate!(PATH, payload, validate_schema: true)
11
+ validate_contexts!(payload.fetch('contexts'), subjects: payload.fetch('subjects'), view: payload.fetch('view'))
12
+ expected = Digest::SHA256.hexdigest(Petticoat.canonical_json(payload.except('digest')))
13
+ raise Error, 'Content digest mismatch' unless payload.fetch('digest') == expected
14
+
15
+ payload
16
+ end
17
+
18
+ # The shape is schema-validated first; these invariants relate sibling fields.
19
+ def self.validate_contexts!(contexts, subjects:, view:)
20
+ contexts.each_value do |context|
21
+ if view == 'resources'
22
+ validate_resource_roles!(context, subjects)
23
+ else
24
+ context.fetch('roles').each_value do |states|
25
+ raise Error, 'Unregistered action subject' unless (states.keys - subjects).empty?
26
+ end
27
+ end
28
+ end
29
+ contexts
30
+ end
31
+
32
+ def self.validate_resource_roles!(context, subjects)
33
+ examined = context.fetch('examined_roles')
34
+ context.fetch('resources').each do |subject, entry|
35
+ possible, unknown = entry.values_at('possible', 'unknown')
36
+ unless subjects.include?(subject) && !possible.intersect?(unknown) &&
37
+ ((possible + unknown) - examined).empty?
38
+ raise Error, 'Resource role lists conflict with examined scope'
39
+ end
40
+ end
41
+ end
42
+ private_class_method :validate_resource_roles!
43
+ end
44
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petticoat
4
+ class Snapshot
5
+ attr_reader :adapter
6
+
7
+ def initialize(ability)
8
+ @adapter = Adapter.new(ability)
9
+ end
10
+
11
+ def state(action, subject)
12
+ return 'unknown' if unsupported?
13
+
14
+ # An object cannot equal an explicit Symbol action, so this queries only
15
+ # manage grants. No permission predicate is evaluated.
16
+ query = action == '*' ? Object.new : action.to_sym
17
+ relevant = adapter.relevant(query, subject)
18
+ return 'unknown' if raw_conditions?(relevant)
19
+
20
+ possibilities = outcomes(relevant)
21
+ return 'conditional' if possibilities.size > 1
22
+
23
+ possibilities.first ? 'unconditional' : 'denied'
24
+ end
25
+
26
+ def actions
27
+ adapter.actions.grep(Symbol).map(&:to_s).sort
28
+ end
29
+
30
+ def aliases
31
+ adapter.aliases.to_h { |key, values| [key.to_s, values.map(&:to_s).uniq.sort] }
32
+ end
33
+
34
+ def subjects
35
+ adapter.rules.flat_map(&:subjects).uniq
36
+ end
37
+
38
+ def evidence(subject_identifier)
39
+ adapter.rules.map do |rule|
40
+ {
41
+ 'allow' => rule.base_behavior,
42
+ 'actions' => rule.actions.filter_map { |action| action.to_s if action.is_a?(Symbol) },
43
+ 'subjects' => rule.subjects.map { |subject| subject_identifier.call(subject) || 'unsupported' },
44
+ 'predicate' => predicate_kind(rule),
45
+ 'attributes_restricted' => !rule.attributes.empty?
46
+ }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def raw_conditions?(relevant)
53
+ relevant.any? { |rule| !rule.conditions.is_a?(Hash) && !rule.conditions.nil? }
54
+ end
55
+
56
+ def outcomes(relevant)
57
+ relevant.reverse_each.reduce([false]) do |values, rule|
58
+ conditional?(rule) ? values | [rule.base_behavior] : [rule.base_behavior]
59
+ end
60
+ end
61
+
62
+ def conditional?(rule)
63
+ rule.block || !rule.attributes.empty? || (rule.conditions && !rule.conditions.empty?)
64
+ end
65
+
66
+ def unsupported?
67
+ !adapter.issues.empty? || adapter.unsupported_subjects? ||
68
+ adapter.rules.any? { |rule| rule.actions.empty? || rule.actions.any? { |action| !action.is_a?(Symbol) } }
69
+ end
70
+
71
+ def predicate_kind(rule)
72
+ return 'block' if rule.block
73
+ return 'none' if rule.conditions.nil? || (rule.conditions.is_a?(Hash) && rule.conditions.empty?)
74
+
75
+ rule.conditions.is_a?(Hash) ? 'hash' : 'unsupported'
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Petticoat
4
+ VERSION = '0.2.0'
5
+ end
data/lib/petticoat.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'cancan'
6
+ require_relative 'petticoat/version'
7
+
8
+ module Petticoat
9
+ class Error < StandardError; end
10
+
11
+ def self.canonical(value)
12
+ case value
13
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value.fetch(key))] }
14
+ when Array then value.map { |item| canonical(item) }
15
+ else value
16
+ end
17
+ end
18
+
19
+ def self.canonical_json(value)
20
+ "#{JSON.pretty_generate(canonical(value))}\n"
21
+ end
22
+ end
23
+
24
+ require_relative 'petticoat/adapter'
25
+ require_relative 'petticoat/snapshot'
26
+ require_relative 'petticoat/document'
27
+ require_relative 'petticoat/schema'
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Generated by Petticoat. The gem owns the executable; the app owns its integration.
5
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
6
+ ENV['BUNDLE_FROZEN'] = 'true'
7
+ require 'bundler/setup'
8
+ load Gem.bin_path('petticoat', 'petticoat')
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Command configuration, not a framework initializer. Only export/check/test load it.
4
+ # Require your application-owned runner here, then replace the error below with:
5
+ # Petticoat::CLI.integration = YourApplication::CapabilityRunner
6
+ #
7
+ # The runner implements call(mode:, arguments:, options:, out:).
8
+ # It owns safe test setup, scenario construction, output paths and publication.
9
+ # It must raise on failure; returning false or an exit code is not sufficient.
10
+ # See the Petticoat README, "Connect your application", for the complete contract.
11
+ raise Petticoat::Error, 'Petticoat: integration is not configured. ' \
12
+ 'Edit config/petticoat.rb to select your application runner.'
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: petticoat
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Maurizio De Magnis <root@olisti.co>
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cancancan
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 3.6.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 3.6.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.21'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.21'
40
+ - !ruby/object:Gem::Dependency
41
+ name: json-schema
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: 6.2.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 6.2.0
54
+ description: Captures completed abilities and conservatively exports deterministic
55
+ role/resource/action catalogues.
56
+ executables:
57
+ - petticoat
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - README.md
62
+ - exe/petticoat
63
+ - lib/petticoat.rb
64
+ - lib/petticoat/adapter.rb
65
+ - lib/petticoat/cli.rb
66
+ - lib/petticoat/document.rb
67
+ - lib/petticoat/installation_path.rb
68
+ - lib/petticoat/installer.rb
69
+ - lib/petticoat/schema.json
70
+ - lib/petticoat/schema.rb
71
+ - lib/petticoat/snapshot.rb
72
+ - lib/petticoat/version.rb
73
+ - templates/bin/petticoat
74
+ - templates/config/petticoat.rb
75
+ homepage: https://source.olisti.co/olisti.co/petticoat
76
+ licenses:
77
+ - Nonstandard
78
+ metadata:
79
+ allowed_push_host: https://rubygems.org
80
+ rubygems_mfa_required: 'true'
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 4.0.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 4.0.16
96
+ specification_version: 4
97
+ summary: Observed UI capability catalogues from CanCanCan abilities
98
+ test_files: []