pwn 0.5.783 → 0.5.785
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 +4 -4
- data/.ruby-version +1 -1
- data/Gemfile +8 -8
- data/README.md +37 -0
- data/Rakefile +19 -1
- data/bin/pwn +2 -1
- data/bin/pwn_setup +1 -1
- data/bin/pwn_web_cache_deception +35 -7
- data/build_gem.sh +1 -1
- data/documentation/AI-Integration.md +7 -5
- data/documentation/Agent-Tool-Registry.md +2 -2
- data/documentation/Backup-Retention.md +82 -0
- data/documentation/Configuration.md +10 -6
- data/documentation/Contributing.md +1 -1
- data/documentation/Cron.md +64 -35
- data/documentation/Diagrams.md +2 -2
- data/documentation/General-PWN-Usage.md +4 -3
- data/documentation/Home.md +6 -5
- data/documentation/How-PWN-Works.md +7 -6
- data/documentation/Installation.md +254 -10
- data/documentation/Mission-Cancellation.md +134 -0
- data/documentation/Operability.md +17 -0
- data/documentation/Persistence.md +8 -6
- data/documentation/Plugin-Degradation.md +12 -0
- data/documentation/Plugins.md +3 -2
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/Runtime-Source-Protection.md +162 -0
- data/documentation/Transparent-Browser.md +11 -0
- data/documentation/Troubleshooting.md +3 -2
- data/documentation/What-is-PWN.md +2 -2
- data/documentation/YubiKey-Vaults.md +183 -0
- data/documentation/YubiKey.md +339 -0
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +5 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +5 -3
- data/documentation/diagrams/dot/plugin-ecosystem.dot +2 -2
- data/documentation/diagrams/persistence-filesystem.svg +172 -142
- data/documentation/diagrams/plugin-ecosystem.svg +64 -64
- data/documentation/pwn-ai-Agent.md +10 -4
- data/etc/default_cron/session_cleanup.rb +1245 -22
- data/etc/default_skills/pwn/ai/agent/mission_namespace/SKILL.md +21 -0
- data/etc/default_skills/pwn/ai/agent/tools/browser_act/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +27 -26
- data/etc/default_skills/pwn/ai/agent/tools/recon_run/SKILL.md +25 -15
- data/etc/default_skills/pwn/ai/agent/tools/workspace_tool/SKILL.md +24 -0
- data/etc/default_skills/pwn/ai/server_tools/SKILL.md +25 -0
- data/etc/default_skills/pwn/backups/SKILL.md +54 -0
- data/etc/default_skills/pwn/plugins/recon/SKILL.md +12 -1
- data/etc/default_skills/pwn/plugins/yubikey/SKILL.md +157 -0
- data/etc/default_skills/pwn/plugins/yubikey/fido/SKILL.md +189 -0
- data/etc/default_skills/pwn/plugins/yubikey/hsmauth/SKILL.md +83 -0
- data/etc/default_skills/pwn/plugins/yubikey/management/SKILL.md +80 -0
- data/etc/default_skills/pwn/plugins/yubikey/oath/SKILL.md +115 -0
- data/etc/default_skills/pwn/plugins/yubikey/openpgp/SKILL.md +144 -0
- data/etc/default_skills/pwn/plugins/yubikey/otp/SKILL.md +97 -0
- data/etc/default_skills/pwn/plugins/yubikey/piv/SKILL.md +110 -0
- data/etc/default_skills/pwn/plugins/yubikey/protocol/SKILL.md +21 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/SKILL.md +75 -0
- data/etc/default_skills/pwn/plugins/yubikey/security_domain/scp/SKILL.md +46 -0
- data/etc/default_skills/pwn/plugins/yubikey/transport/SKILL.md +98 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +13 -1
- data/lib/pwn/ai/agent/dispatch.rb +26 -15
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +62 -25
- data/lib/pwn/ai/agent/loop.rb +17 -1
- data/lib/pwn/ai/agent/mission_namespace.rb +331 -0
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/policy_evaluation.rb +8 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +13 -2
- data/lib/pwn/ai/agent/request_runtime.rb +122 -18
- data/lib/pwn/ai/agent/reward.rb +5 -3
- data/lib/pwn/ai/agent/skill_consolidation.rb +3 -2
- data/lib/pwn/ai/agent/skill_review.rb +9 -5
- data/lib/pwn/ai/agent/swarm.rb +35 -16
- data/lib/pwn/ai/agent/tools/browser_act.rb +32 -0
- data/lib/pwn/ai/agent/tools/loot_query.rb +11 -1
- data/lib/pwn/ai/agent/tools/recon_run.rb +11 -1
- data/lib/pwn/ai/agent/tools/workspace_tool.rb +29 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +6 -2
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +8 -33
- data/lib/pwn/ai/cli.rb +34 -5
- data/lib/pwn/ai/gemini.rb +1 -0
- data/lib/pwn/ai/grok.rb +39 -28
- data/lib/pwn/ai/ollama.rb +4 -2
- data/lib/pwn/ai/open_ai.rb +11 -32
- data/lib/pwn/ai/open_web_ui.rb +62 -4
- data/lib/pwn/ai/router.rb +53 -3
- data/lib/pwn/ai/server_tools.rb +231 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/backups.rb +347 -0
- data/lib/pwn/config.rb +70 -39
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/migrate.rb +150 -68
- data/lib/pwn/module_skills.rb +7 -0
- data/lib/pwn/plugins/ai_sandbox.rb +538 -1
- data/lib/pwn/plugins/authentication_helper.rb +74 -4
- data/lib/pwn/plugins/basic_auth.rb +2 -1
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/google_workspace.rb +4 -21
- data/lib/pwn/plugins/jobs.rb +1 -1
- data/lib/pwn/plugins/log.rb +21 -3
- data/lib/pwn/plugins/preflight_checker.rb +34 -9
- data/lib/pwn/plugins/recon.rb +4 -1
- data/lib/pwn/plugins/repl/ai.rb +305 -27
- data/lib/pwn/plugins/repl/ai_console.rb +273 -89
- data/lib/pwn/plugins/repl/ai_swarm.rb +181 -62
- data/lib/pwn/plugins/repl/mesh.rb +3 -16
- data/lib/pwn/plugins/repl/vault.rb +4 -15
- data/lib/pwn/plugins/repl.rb +1 -216
- data/lib/pwn/plugins/sandbox/driver.rb +4 -0
- data/lib/pwn/plugins/transparent_browser.rb +215 -0
- data/lib/pwn/plugins/vault.rb +412 -135
- data/lib/pwn/plugins/yubikey/fido.rb +1288 -0
- data/lib/pwn/plugins/yubikey/hsmauth.rb +439 -0
- data/lib/pwn/plugins/yubikey/management.rb +686 -0
- data/lib/pwn/plugins/yubikey/oath.rb +766 -0
- data/lib/pwn/plugins/yubikey/openpgp.rb +1063 -0
- data/lib/pwn/plugins/yubikey/otp.rb +897 -0
- data/lib/pwn/plugins/yubikey/piv.rb +1315 -0
- data/lib/pwn/plugins/yubikey/protocol.rb +341 -0
- data/lib/pwn/plugins/yubikey/security_domain/scp.rb +482 -0
- data/lib/pwn/plugins/yubikey/security_domain.rb +464 -0
- data/lib/pwn/plugins/yubikey/transport.rb +1017 -0
- data/lib/pwn/plugins/yubikey.rb +844 -0
- data/lib/pwn/plugins.rb +1 -0
- data/lib/pwn/redaction.rb +69 -12
- data/lib/pwn/session_trace.rb +4 -0
- data/lib/pwn/sessions.rb +58 -16
- data/lib/pwn/setup.rb +123 -41
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn/www/app_cobalt_io.rb +16 -5
- data/lib/pwn/www/bug_crowd.rb +16 -5
- data/lib/pwn/www/coinbase_pro.rb +16 -5
- data/lib/pwn/www/github.rb +30 -14
- data/lib/pwn/www/paypal.rb +15 -4
- data/lib/pwn/www/synack.rb +16 -5
- data/lib/pwn/www/twitter.rb +17 -6
- data/lib/pwn.rb +1 -0
- data/scripts/build_gate.rb +58 -0
- data/spec/documentation/installation_md_spec.rb +1 -1
- data/spec/fixtures/yubikey/apdu_frames.json +162 -0
- data/spec/integration/authentication_helper_oath_spec.rb +117 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/controller_spill_spec.rb +48 -0
- data/spec/integration/harness_process_spec.rb +97 -0
- data/spec/integration/mission_cancel_spec.rb +79 -0
- data/spec/integration/mission_curses_pty_spec.rb +44 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/integration/readme_security_spec.rb +47 -0
- data/spec/integration/web_cache_deception_yubikey_spec.rb +159 -0
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +6 -3
- data/spec/lib/pwn/ai/agent/dispatch_vault_safety_spec.rb +86 -0
- data/spec/lib/pwn/ai/agent/injection_guard_spec.rb +2 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +4 -4
- data/spec/lib/pwn/ai/agent/loop_artifacts_spec.rb +5 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +104 -29
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +138 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +48 -0
- data/spec/lib/pwn/ai/agent/shell_search_effect_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/solve_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/tools/browser_act_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/tools/loot_query_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/ai/agent/tools/recon_run_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/ai/agent/tools/workspace_tool_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +7 -0
- data/spec/lib/pwn/ai/anthropic_vault_enrollment_spec.rb +37 -0
- data/spec/lib/pwn/ai/cli_spec.rb +6 -9
- data/spec/lib/pwn/ai/oauth_vault_persistence_spec.rb +195 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +2 -2
- data/spec/lib/pwn/ai/open_web_ui_workspace_spec.rb +36 -0
- data/spec/lib/pwn/ai/router_spec.rb +31 -3
- data/spec/lib/pwn/ai/server_tools_spec.rb +39 -0
- data/spec/lib/pwn/backups_spec.rb +114 -0
- data/spec/lib/pwn/config_redaction_yubikey_spec.rb +28 -0
- data/spec/lib/pwn/config_yubikey_spec.rb +92 -0
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/migrate_spec.rb +108 -5
- data/spec/lib/pwn/migrate_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/module_skills_spec.rb +8 -0
- data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +230 -0
- data/spec/lib/pwn/plugins/authentication_helper_native_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/plugins/authentication_helper_yubikey_spec.rb +90 -0
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +1 -1
- data/spec/lib/pwn/plugins/google_workspace_spec.rb +17 -35
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/log_yubikey_spec.rb +47 -0
- data/spec/lib/pwn/plugins/preflight_checker_native_yubikey_spec.rb +19 -0
- data/spec/lib/pwn/plugins/preflight_checker_yubikey_spec.rb +55 -0
- data/spec/lib/pwn/plugins/recon_yubikey_spec.rb +43 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +125 -78
- data/spec/lib/pwn/plugins/repl/ai_legacy_containment_spec.rb +200 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +30 -16
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_yubikey_spec.rb +103 -0
- data/spec/lib/pwn/plugins/repl/ai_yubikey_spec.rb +48 -0
- data/spec/lib/pwn/plugins/repl/mesh_vault_persistence_spec.rb +149 -0
- data/spec/lib/pwn/plugins/repl/vault_yaml_serialization_spec.rb +11 -0
- data/spec/lib/pwn/plugins/repl/vault_yubikey_spec.rb +39 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +56 -5
- data/spec/lib/pwn/plugins/transparent_browser_jev_spec.rb +46 -0
- data/spec/lib/pwn/plugins/vault_native_yubikey_spec.rb +29 -0
- data/spec/lib/pwn/plugins/vault_publication_safety_spec.rb +258 -0
- data/spec/lib/pwn/plugins/vault_store_safety_spec.rb +241 -0
- data/spec/lib/pwn/plugins/vault_yubikey_safety_spec.rb +168 -0
- data/spec/lib/pwn/plugins/vault_yubikey_spec.rb +128 -0
- data/spec/lib/pwn/plugins/vault_yubikey_vector_spec.rb +45 -0
- data/spec/lib/pwn/plugins/yubikey/fido_spec.rb +854 -0
- data/spec/lib/pwn/plugins/yubikey/hsmauth_spec.rb +235 -0
- data/spec/lib/pwn/plugins/yubikey/management_spec.rb +277 -0
- data/spec/lib/pwn/plugins/yubikey/oath_spec.rb +349 -0
- data/spec/lib/pwn/plugins/yubikey/openpgp_spec.rb +810 -0
- data/spec/lib/pwn/plugins/yubikey/otp_spec.rb +337 -0
- data/spec/lib/pwn/plugins/yubikey/piv_spec.rb +646 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_secure_spec.rb +28 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_spec.rb +149 -0
- data/spec/lib/pwn/plugins/yubikey/protocol_vectors_spec.rb +18 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/scp_spec.rb +256 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain/support.rb +153 -0
- data/spec/lib/pwn/plugins/yubikey/security_domain_spec.rb +296 -0
- data/spec/lib/pwn/plugins/yubikey/transport_spec.rb +760 -0
- data/spec/lib/pwn/plugins/yubikey_security_spec.rb +260 -0
- data/spec/lib/pwn/plugins/yubikey_spec.rb +209 -0
- data/spec/lib/pwn/plugins/yubikey_terminal_spec.rb +147 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/redaction_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +167 -2
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +335 -2
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +403 -0
- data/spec/lib/pwn/sessions_spec.rb +112 -0
- data/spec/lib/pwn/setup_native_yubikey_spec.rb +66 -0
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/lib/pwn/setup_vault_yubikey_spec.rb +61 -0
- data/spec/lib/pwn/setup_yubikey_spec.rb +40 -0
- data/spec/lib/pwn/www/login_yubikey_spec.rb +273 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/spec/support/harness/cli_faults.rb +13 -9
- data/spec/support/harness/controller_spill.rb +26 -0
- data/spec/support/harness/controller_state.rb +54 -0
- data/spec/support/harness/evaluate.rb +12 -1
- data/spec/support/harness/experiments.rb +1 -1
- data/spec/support/harness/fake_provider.rb +22 -6
- data/spec/support/harness/fault_preload.rb +3 -1
- data/spec/support/harness/mission_console.rb +54 -0
- data/spec/support/harness/mission_curses_pty.rb +41 -0
- data/spec/support/harness/mission_dispatch.rb +30 -0
- data/spec/support/harness/mission_fixture_preload.rb +24 -0
- data/spec/support/harness/mission_job_lifetime.rb +25 -0
- data/spec/support/harness/mission_legacy.rb +25 -0
- data/spec/support/harness/mission_normal_exit.rb +34 -0
- data/spec/support/harness/mission_swarm.rb +23 -0
- data/spec/support/harness/mission_unsupported.rb +29 -0
- data/spec/support/harness/package_acceptance.rb +33 -5
- data/spec/support/harness/session_acceptance.rb +4 -4
- data/spec/support/harness/swarm_acceptance.rb +1 -1
- data/spec/support/harness/swarm_control.rb +44 -0
- data/spec/support/mission_namespace.rb +14 -0
- data/spec/support/mission_pipe_queue.rb +33 -0
- data/third_party/pwn_rdoc.jsonl +599 -15
- metadata +140 -17
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe PWN::Backups do
|
|
6
|
+
before do
|
|
7
|
+
@home = Dir.mktmpdir('pwn-backups-')
|
|
8
|
+
Thread.current[:pwn_backup_root] = nil
|
|
9
|
+
allow(Dir).to receive(:home).and_return(@home)
|
|
10
|
+
@source = File.join(@home, 'source')
|
|
11
|
+
File.write(@source, 'original')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
after { FileUtils.remove_entry(@home) }
|
|
15
|
+
|
|
16
|
+
it 'retains no more than seven generations across producers' do
|
|
17
|
+
10.times { described_class.capture(path: @source, label: 'fixture') }
|
|
18
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
19
|
+
expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'keeps interrupted transactions prepared and refuses an eighth pinned generation' do
|
|
23
|
+
7.times do
|
|
24
|
+
expect do
|
|
25
|
+
described_class.transaction do
|
|
26
|
+
described_class.capture(path: @source, label: 'fixture')
|
|
27
|
+
raise IOError, 'interrupted'
|
|
28
|
+
end
|
|
29
|
+
end.to raise_error(IOError, 'interrupted')
|
|
30
|
+
end
|
|
31
|
+
expect { described_class.capture(path: @source) }.to raise_error(IOError, /capacity/)
|
|
32
|
+
expect(File.read(@source)).to eq('original')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'pins actual evidence paths but not quoted inventory prose' do
|
|
36
|
+
copies = 7.times.map { described_class.capture(path: @source) }
|
|
37
|
+
File.write(File.join(@home, '.pwn', 'learning.jsonl'), JSON.generate(evidence_path: copies.first, prose: "old path #{copies[1]}"))
|
|
38
|
+
described_class.capture(path: @source)
|
|
39
|
+
expect(File).to exist(copies.first)
|
|
40
|
+
expect(File).not_to exist(copies[1])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'fails admission on malformed reference data without losing any recovery copy' do
|
|
44
|
+
copies = 7.times.map { described_class.capture(path: @source) }
|
|
45
|
+
File.write(File.join(@home, '.pwn', 'learning.jsonl'), '{broken')
|
|
46
|
+
expect { described_class.capture(path: @source) }.to raise_error(IOError, /references/)
|
|
47
|
+
expect(copies.all? { |path| File.file?(path) }).to be(true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'uses one generation for nested producers and preserves failed result recovery' do
|
|
51
|
+
copies = []
|
|
52
|
+
described_class.transaction do
|
|
53
|
+
copies << described_class.capture(path: @source, label: 'learning')
|
|
54
|
+
copies << described_class.capture(path: @source, label: 'preferences')
|
|
55
|
+
{ ok: false, error: 'fixture' }
|
|
56
|
+
end
|
|
57
|
+
generations = copies.map { |path| File.dirname(path, 2) }.uniq
|
|
58
|
+
expect(generations.length).to eq(1)
|
|
59
|
+
expect(JSON.parse(File.read(File.join(generations.first, '.generation.json')))['state']).to eq('prepared')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'serializes separate processes at the global seven-folder bound' do
|
|
63
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
64
|
+
pids = 4.times.map do
|
|
65
|
+
fork do
|
|
66
|
+
4.times { described_class.capture(path: @source) }
|
|
67
|
+
exit! 0
|
|
68
|
+
rescue StandardError
|
|
69
|
+
exit! 1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
expect(pids.map { |pid| Process.wait2(pid).last.exitstatus }).to eq([0, 0, 0, 0])
|
|
73
|
+
expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'requires exact manifest acknowledgement to release interrupted recovery' do
|
|
77
|
+
backup = nil
|
|
78
|
+
expect do
|
|
79
|
+
described_class.transaction do
|
|
80
|
+
backup = described_class.capture(path: @source)
|
|
81
|
+
raise IOError, 'interrupted'
|
|
82
|
+
end
|
|
83
|
+
end.to raise_error(IOError)
|
|
84
|
+
generation = File.basename(File.dirname(backup, 2))
|
|
85
|
+
manifest = File.join(File.dirname(backup, 2), '.generation.json')
|
|
86
|
+
expect { described_class.reconcile(generation: generation, confirmed: true, expected_sha256: 'wrong') }.to raise_error(IOError)
|
|
87
|
+
result = described_class.reconcile(generation: generation, confirmed: true, expected_sha256: Digest::SHA256.file(manifest).hexdigest, pinned: false)
|
|
88
|
+
expect(result).to include(state: 'complete', pinned: false)
|
|
89
|
+
expect(File.read(backup)).to eq('original')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'relocates only explicitly selected unreferenced legacy copies after quiescence confirmation' do
|
|
93
|
+
root = File.join(@home, '.pwn')
|
|
94
|
+
FileUtils.mkdir_p(root)
|
|
95
|
+
legacy = File.join(root, 'learning.jsonl.bak-lean-20200101')
|
|
96
|
+
File.write(legacy, 'legacy bytes')
|
|
97
|
+
expect(described_class.relocate_legacy(paths: [legacy])[:moved]).to eq([])
|
|
98
|
+
expect { described_class.relocate_legacy(paths: [legacy], dry_run: false) }.to raise_error(ArgumentError, /writers/)
|
|
99
|
+
File.write(File.join(root, 'memory.json'), JSON.generate(evidence: legacy))
|
|
100
|
+
expect { described_class.relocate_legacy(paths: [legacy], dry_run: false, writers_stopped: true) }.to raise_error(IOError, /referenced/)
|
|
101
|
+
File.write(File.join(root, 'memory.json'), '{}')
|
|
102
|
+
result = described_class.relocate_legacy(paths: [legacy], dry_run: false, writers_stopped: true)
|
|
103
|
+
expect(File).not_to exist(legacy)
|
|
104
|
+
expect(File.read(result[:moved].fetch(legacy))).to eq('legacy bytes')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'tightens an owned group-writable backup directory before writing' do
|
|
108
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
109
|
+
FileUtils.mkdir_p(root)
|
|
110
|
+
File.chmod(0o775, root)
|
|
111
|
+
described_class.capture(path: @source, label: 'fixture')
|
|
112
|
+
expect(File.stat(root).mode & 0o777).to eq(0o700)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe PWN::Config, 'YubiKey configuration redaction' do
|
|
6
|
+
it 'redacts string-keyed hardware settings inside arrays without hiding public selectors' do
|
|
7
|
+
config = { devices: [{ 'PIN' => '1', 'management-key' => 'synthetic-key', 'oath-secret' => 'synthetic-oath', 'serial' => '12345678', 'slot' => 2 }] }
|
|
8
|
+
result = described_class.redact_sensitive_artifacts(config: config)
|
|
9
|
+
masked = result[:devices].first
|
|
10
|
+
expect(masked.values_at('PIN', 'management-key', 'oath-secret').uniq).to eq(['>>> REDACTED >>> USE `pwn-vault` FOR ADMINISTRATION <<< REDACTED <<<'])
|
|
11
|
+
expect(masked).to include('serial' => '12345678', 'slot' => 2)
|
|
12
|
+
expect(config.dig(:devices, 0, 'PIN')).to eq('1')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'uses the same constant mask for short PINs and hardware secrets while retaining public selectors' do
|
|
16
|
+
fields = %i[pin new_pin admin_pin puk new_puk management_key new_management_key management_password access_code new_access_code oath_secret oath_password unlock_code unlock_password lock_code new_lock_code reset_code scp scp_key scp_keys scp03_key scp11_key static_password hmac_secret]
|
|
17
|
+
values = fields.to_h { |field| [field, '123456'] }
|
|
18
|
+
values[:pin] = '1'
|
|
19
|
+
values[:puk] = '87654321'
|
|
20
|
+
config = { plugins: { yubikey: values.merge(serial: '12345678', slot: 2, reader: 'fixture-reader') } }
|
|
21
|
+
before = Marshal.dump(config)
|
|
22
|
+
redacted = described_class.redact_sensitive_artifacts(config: config).dig(:plugins, :yubikey)
|
|
23
|
+
mask = described_class.redact_sensitive_artifacts(config: { password: 'different-value' })[:password]
|
|
24
|
+
fields.each { |field| expect(redacted[field]).to eq(mask), "#{field} must use the constant mask" }
|
|
25
|
+
expect(redacted).to include(serial: '12345678', slot: 2, reader: 'fixture-reader')
|
|
26
|
+
expect(Marshal.dump(config)).to eq(before)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
RSpec.describe PWN::Config, 'hardware vault configuration' do
|
|
7
|
+
around do |example|
|
|
8
|
+
Dir.mktmpdir('config-yubikey-') do |root|
|
|
9
|
+
@root = root
|
|
10
|
+
example.run
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(Dir).to receive(:home).and_return(@root)
|
|
16
|
+
stub_const('PWN::Env', {})
|
|
17
|
+
stub_const('PWN::EnvRedacted', {})
|
|
18
|
+
stub_const('PWN::Plugins::YubiKey', double('synthetic YubiKey'))
|
|
19
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response) do |opts|
|
|
20
|
+
OpenSSL::HMAC.digest('SHA1', 'synthetic-slot-secret', opts[:challenge])
|
|
21
|
+
end
|
|
22
|
+
allow(described_class).to receive(:install_default_skills)
|
|
23
|
+
allow(described_class).to receive(:load_skills)
|
|
24
|
+
allow(PWN::Memory).to receive(:load)
|
|
25
|
+
allow(PWN::Cron).to receive(:install_defaults)
|
|
26
|
+
allow(PWN::Migrate).to receive(:needed?).and_return(false)
|
|
27
|
+
@path = File.join(@root, 'pwn.yaml')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'rejects an invalid hardware selector rather than falling back to a software sidecar' do
|
|
31
|
+
expect { described_class.default_env(pwn_env_path: @path, yubikey: false) }.to raise_error(ArgumentError)
|
|
32
|
+
expect(File).not_to exist(@path)
|
|
33
|
+
expect(File).not_to exist("#{@path}.decryptor")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'creates the default hardware configuration path when called directly' do
|
|
37
|
+
described_class.default_env(yubikey: { slot: 2 })
|
|
38
|
+
path = File.join(@root, '.pwn', 'pwn.yaml')
|
|
39
|
+
expect(PWN::Plugins::Vault.dump(file: path)).to eq(described_class.env_template)
|
|
40
|
+
expect(File).not_to exist("#{path}.decryptor")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'preserves multiline template values containing leading colons during creation' do
|
|
44
|
+
template = Marshal.load(Marshal.dump(described_class.env_template))
|
|
45
|
+
text = "before\n:leading-colon\nafter\n"
|
|
46
|
+
template[:ai][:ollama][:system_role_content] = text
|
|
47
|
+
allow(described_class).to receive(:env_template).and_return(template)
|
|
48
|
+
described_class.default_env(pwn_env_path: @path, yubikey: {})
|
|
49
|
+
expect(PWN::Plugins::Vault.dump(file: @path).dig(:ai, :ollama, :system_role_content)).to eq(text)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'retains software creation by default and honors the explicitly selected legacy decryptor' do
|
|
53
|
+
sidecar = File.join(@root, 'custom-sidecar')
|
|
54
|
+
expect(PWN::Plugins::YubiKey).not_to receive(:challenge_response)
|
|
55
|
+
described_class.refresh_env(pwn_env_path: @path, pwn_dec_path: sidecar)
|
|
56
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(false)
|
|
57
|
+
credentials = YAML.safe_load_file(sidecar, symbolize_names: true)
|
|
58
|
+
expect(PWN::Plugins::Vault.dump(credentials.merge(file: @path))).to eq(described_class.env_template)
|
|
59
|
+
described_class.refresh_env(pwn_env_path: @path, pwn_dec_path: sidecar)
|
|
60
|
+
expect(PWN::Env.dig(:driver_opts, :pwn_dec_path)).to eq(sidecar)
|
|
61
|
+
expect(File).not_to exist("#{@path}.decryptor")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'creates a hardware vault only on explicit opt-in, without staging plaintext or making a sidecar' do
|
|
65
|
+
described_class.refresh_env(pwn_env_path: @path, yubikey: { slot: 2 })
|
|
66
|
+
expect(PWN::Plugins::Vault.hardware_encrypted?(file: @path)).to be(true)
|
|
67
|
+
expect(PWN::Plugins::Vault.dump(file: @path)).to eq(described_class.env_template)
|
|
68
|
+
expect(File).not_to exist("#{@path}.decryptor")
|
|
69
|
+
expect(File.stat(@path).mode & 0o777).to eq(0o600)
|
|
70
|
+
expect(PWN::Env).not_to have_key(:yubikey)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'leaves no plaintext configuration behind when opted-in hardware creation fails' do
|
|
74
|
+
allow(PWN::Plugins::YubiKey).to receive(:challenge_response).and_raise(IOError, 'synthetic device unavailable')
|
|
75
|
+
expect { described_class.default_env(pwn_env_path: @path, yubikey: { slot: 2 }) }.to raise_error(IOError)
|
|
76
|
+
expect(File).not_to exist(@path)
|
|
77
|
+
expect(File).not_to exist("#{@path}.decryptor")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'refreshes a self-describing hardware vault without a decryptor or software-key prompt' do
|
|
81
|
+
original = { ai: { active: 'ollama', ollama: { model: 'fixture-model' } }, retained: 'fixture-value' }
|
|
82
|
+
bytes = PWN::Plugins::Vault.encrypt_data(data: YAML.dump(original), yubikey: { slot: 2, serial: '12345678' })
|
|
83
|
+
File.binwrite(@path, bytes)
|
|
84
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
85
|
+
described_class.refresh_env(pwn_env_path: @path)
|
|
86
|
+
expect(PWN::Env.dig(:ai, :ollama, :model)).to eq('fixture-model')
|
|
87
|
+
expect(PWN::Env[:retained]).to eq('fixture-value')
|
|
88
|
+
expect(PWN::Env.dig(:driver_opts, :pwn_env_path)).to eq(@path)
|
|
89
|
+
expect(File.binread(@path)).to eq(bytes)
|
|
90
|
+
expect(File).not_to exist("#{@path}.decryptor")
|
|
91
|
+
end
|
|
92
|
+
end
|
data/spec/lib/pwn/cron_spec.rb
CHANGED
|
@@ -34,6 +34,48 @@ describe PWN::Cron do
|
|
|
34
34
|
FileUtils.remove_entry(@tmp) if @tmp && Dir.exist?(@tmp)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
it 'removes only a schedule without native effects when explicitly requested' do
|
|
38
|
+
target = described_class.create(name: 'curriculum_practice_nightly', enabled: false, sync_native: false)
|
|
39
|
+
other = described_class.create(name: 'custom', enabled: false, sync_native: false)
|
|
40
|
+
log = File.join(@tmp, "#{target[:id]}.log")
|
|
41
|
+
File.write(log, 'evidence')
|
|
42
|
+
expect(described_class).not_to receive(:remove_native_job)
|
|
43
|
+
described_class.remove(id: target[:id], sync_native: false)
|
|
44
|
+
expect(described_class.list).to eq(other[:id] => other)
|
|
45
|
+
expect(File.read(log)).to eq('evidence')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'does not resurrect a schedule removed during execution or erase concurrent creations' do
|
|
49
|
+
target = described_class.create(name: 'running', ruby: '42', sync_native: false)
|
|
50
|
+
allow(described_class).to receive(:eval) do
|
|
51
|
+
described_class.remove(id: target[:id], sync_native: false)
|
|
52
|
+
described_class.create(name: 'concurrent', ruby: '43', sync_native: false)
|
|
53
|
+
42
|
|
54
|
+
end
|
|
55
|
+
described_class.run(id: target[:id])
|
|
56
|
+
expect(described_class.list.values.map { |row| row[:name] }).to eq(['concurrent'])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'refuses destructive registry updates when existing YAML is corrupt' do
|
|
60
|
+
File.write(described_class.jobs_file, '--- [broken')
|
|
61
|
+
before = File.binread(described_class.jobs_file)
|
|
62
|
+
expect { described_class.remove(id: 'absent', sync_native: false) }.to raise_error(IOError)
|
|
63
|
+
expect(File.binread(described_class.jobs_file)).to eq(before)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'preserves concurrent process creations with atomic registry readback' do
|
|
67
|
+
count = 4
|
|
68
|
+
pids = Array.new(count) do |index|
|
|
69
|
+
fork do
|
|
70
|
+
5.times { |n| described_class.create(name: "process-#{index}-#{n}", ruby: '42', enabled: false, sync_native: false) }
|
|
71
|
+
exit! 0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
pids.each { |pid| expect(Process.wait2(pid).last.success?).to be(true) }
|
|
75
|
+
expect(described_class.list.length).to eq(count * 5)
|
|
76
|
+
expect(Dir.glob("#{described_class.jobs_file}.*.tmp")).to be_empty
|
|
77
|
+
end
|
|
78
|
+
|
|
37
79
|
it 'exposes worker control methods' do
|
|
38
80
|
%i[
|
|
39
81
|
due? due_jobs run_due tick worker_loop
|
|
@@ -155,9 +197,16 @@ describe PWN::Cron do
|
|
|
155
197
|
expect(res[:started]).to be true
|
|
156
198
|
end
|
|
157
199
|
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe PWN::Cron do
|
|
158
203
|
describe 'OS-agnostic scheduler' do
|
|
159
204
|
before do
|
|
160
|
-
|
|
205
|
+
# install_defaults owns siblings of cron_dir (maintenance/cache/tmp).
|
|
206
|
+
# Give it a complete private root, never the shared Dir.tmpdir parent.
|
|
207
|
+
@root = Dir.mktmpdir('pwn-cron-os-spec-')
|
|
208
|
+
@tmp = File.join(@root, 'cron')
|
|
209
|
+
FileUtils.mkdir_p(@tmp)
|
|
161
210
|
stub_const('PWN::Cron::CRON_DIR', @tmp)
|
|
162
211
|
stub_const('PWN::Cron::JOBS_FILE', File.join(@tmp, 'jobs.yml'))
|
|
163
212
|
stub_const('PWN::Cron::PID_FILE', File.join(@tmp, 'worker.pid'))
|
|
@@ -167,7 +216,53 @@ describe PWN::Cron do
|
|
|
167
216
|
|
|
168
217
|
after do
|
|
169
218
|
ENV.delete('PWN_CRON_DIR')
|
|
170
|
-
FileUtils.remove_entry(@
|
|
219
|
+
FileUtils.remove_entry(@root) if @root && Dir.exist?(@root)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it 'keeps default housekeeping off every native installer' do
|
|
223
|
+
allow(described_class).to receive(:scheduler_backend).and_return(:systemd_user)
|
|
224
|
+
expect(described_class).not_to receive(:systemd_user)
|
|
225
|
+
described_class.install_defaults
|
|
226
|
+
expect(File.file?(File.join(@root, 'maintenance', 'policy.json'))).to be(true)
|
|
227
|
+
job = described_class.list.values.find { |row| row[:name] == 'session_cleanup' }
|
|
228
|
+
%i[install_native_job install_systemd_user_job install_crontab_job install_launchd_job install_schtasks_job].each do |method|
|
|
229
|
+
expect(described_class.public_send(method, job: job, apply: true)).to include(skipped: true, reason: :housekeeping_internal_only)
|
|
230
|
+
end
|
|
231
|
+
expect(Dir.glob(File.join(@tmp, 'native', "*#{job[:id]}*"))).to be_empty
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'setup uses only the existing internal worker even with systemd available' do
|
|
235
|
+
allow(described_class).to receive(:systemd_user_available?).and_return(true)
|
|
236
|
+
expect(described_class).not_to receive(:install_scheduler)
|
|
237
|
+
expect(described_class).not_to receive(:uninstall_scheduler)
|
|
238
|
+
expect(described_class).not_to receive(:install_native_job)
|
|
239
|
+
allow(described_class).to receive(:worker_status).and_return({ running: false }, { running: true, pid: 123 })
|
|
240
|
+
expect(described_class).to receive(:ensure_worker).with(restart: false, interval: nil).once.and_return(started: true, pid: 123)
|
|
241
|
+
expect(PWN::Setup.ensure_cron(io: StringIO.new)).to include(ok: true, backend: :worker)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it 'preserves custom cron bytes and runs housekeeping identically by tick and manual run' do
|
|
245
|
+
custom = described_class.create(name: 'operator-custom', schedule: '12 9 * * *', ruby: '41', enabled: false)
|
|
246
|
+
described_class.install_defaults
|
|
247
|
+
expect(described_class.list[custom[:id]]).to eq(custom)
|
|
248
|
+
jobs = described_class.list
|
|
249
|
+
jobs.each_value { |job| job[:enabled] = false }
|
|
250
|
+
job = jobs.values.find { |row| row[:name] == 'session_cleanup' }
|
|
251
|
+
job.merge!(enabled: true, schedule: 'every 1s', ruby: 'PWN::SessionCleanup.maintain(dry_run: false)')
|
|
252
|
+
File.write(described_class.jobs_file, YAML.dump(jobs))
|
|
253
|
+
expect(PWN::SessionCleanup).to receive(:maintain).with(dry_run: false).twice.and_return(deleted: 0)
|
|
254
|
+
expect(described_class.run(id: job[:id])).to include(status: 'success', result: { deleted: 0 })
|
|
255
|
+
expect(described_class.tick(now: Time.now + 2)).to contain_exactly(include(id: job[:id], status: 'success'))
|
|
256
|
+
expect(described_class.list[custom[:id]]).to eq(custom)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it 'native worker markers exclude housekeeping while leaving custom jobs runnable' do
|
|
260
|
+
allow(ENV).to receive(:[]).and_call_original
|
|
261
|
+
allow(ENV).to receive(:[]).with('PWN_CRON_NATIVE').and_return('1')
|
|
262
|
+
house = described_class.create(name: 'session_cleanup', ruby: 'raise "must not run"')
|
|
263
|
+
custom = described_class.create(name: 'operator-custom', schedule: 'every 1s', ruby: '41')
|
|
264
|
+
expect(described_class.tick).to contain_exactly(include(id: custom[:id], status: 'success'))
|
|
265
|
+
expect(described_class.list[house[:id]][:last_run]).to be_nil
|
|
171
266
|
end
|
|
172
267
|
|
|
173
268
|
it 'exposes scheduler control methods' do
|
|
@@ -178,12 +273,12 @@ describe PWN::Cron do
|
|
|
178
273
|
].each do |m|
|
|
179
274
|
expect(PWN::Cron).to respond_to(m)
|
|
180
275
|
end
|
|
181
|
-
expect(PWN::Cron::DEFAULT_JOBS.length).to eq(
|
|
276
|
+
expect(PWN::Cron::DEFAULT_JOBS.length).to eq(1)
|
|
182
277
|
names = PWN::Cron::DEFAULT_JOBS.map { |j| j[:name] }
|
|
183
|
-
expect(names).to
|
|
184
|
-
expect(names).to include('learning_consolidate_nightly')
|
|
278
|
+
expect(names).to eq(['session_cleanup'])
|
|
185
279
|
enabled = PWN::Cron::DEFAULT_JOBS.reject { |j| j[:enabled] == false }.map { |j| j[:name] }
|
|
186
|
-
expect(enabled).to contain_exactly('
|
|
280
|
+
expect(enabled).to contain_exactly('session_cleanup')
|
|
281
|
+
expect(PWN::Cron::CURRICULUM_JOBS.length).to eq(3)
|
|
187
282
|
end
|
|
188
283
|
|
|
189
284
|
it 'picks a native backend per OS without consulting live daemons' do
|
|
@@ -237,6 +332,7 @@ describe PWN::Cron do
|
|
|
237
332
|
expect(body).to include('<key>KeepAlive</key>')
|
|
238
333
|
PWN::Cron.list.each_value do |job|
|
|
239
334
|
next unless job[:enabled]
|
|
335
|
+
next if described_class.send(:housekeeping_job?, job: job)
|
|
240
336
|
|
|
241
337
|
plist = File.join(@tmp, 'native', "com.0dayinc.pwn.cron.job.#{job[:id]}.plist")
|
|
242
338
|
expect(File).to exist(plist)
|
|
@@ -247,6 +343,15 @@ describe PWN::Cron do
|
|
|
247
343
|
end
|
|
248
344
|
end
|
|
249
345
|
|
|
346
|
+
it 'rejects housekeeping execution from a systemd invocation without evaluating the payload' do
|
|
347
|
+
job = described_class.create(name: 'session_cleanup', ruby: 'raise "must not run"')
|
|
348
|
+
allow(File).to receive(:read).and_call_original
|
|
349
|
+
allow(File).to receive(:read).with('/proc/self/cgroup').and_return('0::/user.slice/pwn-cron-worker.service')
|
|
350
|
+
expect(described_class.run(id: job[:id])).to include(skipped: true, reason: :housekeeping_internal_only)
|
|
351
|
+
expect(described_class.list[job[:id]][:last_run]).to be_nil
|
|
352
|
+
expect(described_class.tick).to eq([])
|
|
353
|
+
end
|
|
354
|
+
|
|
250
355
|
it 'systemd backend emits OnCalendar that matches each default schedule' do
|
|
251
356
|
PWN::Cron.install_defaults
|
|
252
357
|
res = PWN::Cron.install_scheduler(backend: :systemd_user, apply: false)
|
|
@@ -256,6 +361,7 @@ describe PWN::Cron do
|
|
|
256
361
|
expect(unit).to include('Restart=always')
|
|
257
362
|
PWN::Cron.list.each_value do |job|
|
|
258
363
|
next unless job[:enabled]
|
|
364
|
+
next if described_class.send(:housekeeping_job?, job: job)
|
|
259
365
|
|
|
260
366
|
tmr = File.join(@tmp, 'native', "pwn-cron-j#{job[:id]}.timer")
|
|
261
367
|
expect(File).to exist(tmr)
|
|
@@ -273,6 +379,7 @@ describe PWN::Cron do
|
|
|
273
379
|
expect(worker).to include('PWN::Cron.ensure_worker')
|
|
274
380
|
PWN::Cron.list.each_value do |job|
|
|
275
381
|
next unless job[:enabled]
|
|
382
|
+
next if described_class.send(:housekeeping_job?, job: job)
|
|
276
383
|
|
|
277
384
|
preview = File.read(File.join(@tmp, 'native', "job-#{job[:id]}.crontab"))
|
|
278
385
|
expect(preview).to include(job[:schedule])
|
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
|
+
describe PWN::Migrate, 'exact migration snapshot scope' do
|
|
6
|
+
include_context 'pwn tmp sandbox'
|
|
7
|
+
|
|
8
|
+
it 'backs up nested registry paths and directory trees without cron runtime copies' do
|
|
9
|
+
FileUtils.mkdir_p(File.join(@tmp, 'cron', 'schedule-backups'))
|
|
10
|
+
FileUtils.mkdir_p(File.join(@tmp, 'skills', 'custom'))
|
|
11
|
+
File.write(File.join(@tmp, 'cron', 'jobs.yml'), "--- []\n")
|
|
12
|
+
File.write(File.join(@tmp, 'cron', 'worker.log'), 'diagnostic')
|
|
13
|
+
File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
|
|
14
|
+
File.write(File.join(@tmp, 'skills', 'custom', 'SKILL.md'), 'custom recovery')
|
|
15
|
+
snapshot = described_class.send(:backup!)
|
|
16
|
+
expect(File.read(File.join(snapshot, 'cron', 'jobs.yml'))).to eq("--- []\n")
|
|
17
|
+
expect(File.read(File.join(snapshot, 'skills', 'custom', 'SKILL.md'))).to eq('custom recovery')
|
|
18
|
+
expect(File).not_to exist(File.join(snapshot, 'cron', 'worker.log'))
|
|
19
|
+
expect(File).not_to exist(File.join(snapshot, 'cron', 'schedule-backups'))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'does not require new cron logs and maintenance copies in recovery coverage' do
|
|
23
|
+
File.write(File.join(@tmp, 'memory.json'), '{}')
|
|
24
|
+
5.times do |i|
|
|
25
|
+
path = File.join(described_class::BACKUP_ROOT, "2026010#{i + 1}_000000")
|
|
26
|
+
FileUtils.mkdir_p(path)
|
|
27
|
+
File.write(File.join(path, 'memory.json'), '{}')
|
|
28
|
+
end
|
|
29
|
+
FileUtils.mkdir_p(File.join(@tmp, 'cron', 'schedule-backups'))
|
|
30
|
+
File.write(File.join(@tmp, 'cron', 'worker.log'), 'new diagnostic')
|
|
31
|
+
File.write(File.join(@tmp, 'cron', 'schedule-backups', 'receipt.json'), '{}')
|
|
32
|
+
result = described_class.prune_backups(dry_run: false, expire_history: true)
|
|
33
|
+
expect(result).to include(deleted: [], keep: 7, scope: 'global')
|
|
34
|
+
expect(File.read(File.join(@tmp, 'cron', 'worker.log'))).to eq('new diagnostic')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
5
38
|
describe PWN::Migrate do
|
|
6
39
|
it 'should display information for authors' do
|
|
7
40
|
authors_response = PWN::Migrate
|
|
@@ -43,6 +76,19 @@ describe PWN::Migrate do
|
|
|
43
76
|
allow(PWN::Config).to receive(:pwn_skills_path).and_return(File.join(@tmp, 'skills'))
|
|
44
77
|
end
|
|
45
78
|
|
|
79
|
+
it 'upgrades the existing three-folder policy to seven without pruning legacy data' do
|
|
80
|
+
FileUtils.mkdir_p(described_class::BACKUP_ROOT, mode: 0o700)
|
|
81
|
+
policy = File.join(described_class::BACKUP_ROOT, '.retention-policy.json')
|
|
82
|
+
File.write(policy, JSON.generate(version: 1, keep: 3, pins: [], expire_history: true))
|
|
83
|
+
File.write(described_class::SCHEMA_FILE, JSON.generate(schema: 11))
|
|
84
|
+
result = described_class.run(backup: false, io: io)
|
|
85
|
+
expect(result[:applied_migrations]).to eq([12])
|
|
86
|
+
expect(JSON.parse(File.read(policy))).to include('keep' => 7, 'scope' => 'global')
|
|
87
|
+
bytes = File.binread(policy)
|
|
88
|
+
expect(described_class.run(backup: false, io: io)[:applied_migrations]).to eq([])
|
|
89
|
+
expect(File.binread(policy)).to eq(bytes)
|
|
90
|
+
end
|
|
91
|
+
|
|
46
92
|
it '.needed? / .status on a fresh (empty) root' do
|
|
47
93
|
expect(PWN::Migrate.installed_schema).to eq(0)
|
|
48
94
|
expect(PWN::Migrate.needed?).to be true
|
|
@@ -80,7 +126,7 @@ describe PWN::Migrate do
|
|
|
80
126
|
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 2))
|
|
81
127
|
expect(described_class.needed?).to be(true)
|
|
82
128
|
result = described_class.run(fix: false, backup: false, io: io)
|
|
83
|
-
expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10])
|
|
129
|
+
expect(result[:applied_migrations]).to eq([3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
|
84
130
|
expected = Marshal.load(Marshal.dump(original))
|
|
85
131
|
expected['custom']['model'] = nil
|
|
86
132
|
expect(YAML.safe_load_file(path)).to eq(expected)
|
|
@@ -189,7 +235,7 @@ describe PWN::Migrate do
|
|
|
189
235
|
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 3))
|
|
190
236
|
expect(described_class.needed?).to be(true)
|
|
191
237
|
result = described_class.run(fix: true, backup: false, io: io)
|
|
192
|
-
expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10])
|
|
238
|
+
expect(result[:applied_migrations]).to eq([4, 5, 6, 7, 8, 9, 10, 11, 12])
|
|
193
239
|
creds = YAML.safe_load_file(dec, symbolize_names: true)
|
|
194
240
|
user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
|
|
195
241
|
expect(user[:ai][:active]).to eq('grok')
|
|
@@ -215,7 +261,7 @@ describe PWN::Migrate do
|
|
|
215
261
|
PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
|
|
216
262
|
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 4))
|
|
217
263
|
result = described_class.run(fix: true, backup: false, io: io)
|
|
218
|
-
expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10])
|
|
264
|
+
expect(result[:applied_migrations]).to eq([5, 6, 7, 8, 9, 10, 11, 12])
|
|
219
265
|
creds = YAML.safe_load_file(dec, symbolize_names: true)
|
|
220
266
|
user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
|
|
221
267
|
expect(user.dig(:ai, :agent, :skill_review)).to eq('off')
|
|
@@ -240,7 +286,7 @@ describe PWN::Migrate do
|
|
|
240
286
|
File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
|
|
241
287
|
PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
|
|
242
288
|
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 5))
|
|
243
|
-
expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10])
|
|
289
|
+
expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([6, 7, 8, 9, 10, 11, 12])
|
|
244
290
|
creds = YAML.safe_load_file(dec, symbolize_names: true)
|
|
245
291
|
user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
|
|
246
292
|
theme = user.dig(:ai, :tui, :theme)
|
|
@@ -287,7 +333,7 @@ describe PWN::Migrate do
|
|
|
287
333
|
File.write(yaml, YAML.dump(stale).gsub(/^(\s*):/, '\1'))
|
|
288
334
|
PWN::Plugins::Vault.create(file: yaml, decryptor_file: dec)
|
|
289
335
|
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: 8))
|
|
290
|
-
expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10])
|
|
336
|
+
expect(described_class.run(fix: true, backup: false, io: io)[:applied_migrations]).to eq([9, 10, 11, 12])
|
|
291
337
|
creds = YAML.safe_load_file(dec, symbolize_names: true)
|
|
292
338
|
user = PWN::Plugins::Vault.dump(file: yaml, key: creds[:key], iv: creds[:iv])
|
|
293
339
|
long_fast = user.dig(:plugins, :meshtastic, :channel, :LongFast)
|
|
@@ -311,3 +357,60 @@ describe PWN::Migrate do
|
|
|
311
357
|
end
|
|
312
358
|
end
|
|
313
359
|
end
|
|
360
|
+
|
|
361
|
+
describe PWN::Migrate, 'backup housekeeping' do
|
|
362
|
+
include_context 'pwn tmp sandbox'
|
|
363
|
+
let(:io) { StringIO.new }
|
|
364
|
+
|
|
365
|
+
it 'does not snapshot or rewrite the stamp on a no-op migration' do
|
|
366
|
+
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: described_class::SCHEMA_VERSION))
|
|
367
|
+
before = File.binread(File.join(@tmp, '.schema'))
|
|
368
|
+
expect(described_class).not_to receive(:backup!)
|
|
369
|
+
result = described_class.run(io: io)
|
|
370
|
+
expect(result[:backup]).to be_nil
|
|
371
|
+
expect(File.binread(File.join(@tmp, '.schema'))).to eq(before)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
it 'leaves legacy snapshots alone instead of running a competing pruner' do
|
|
375
|
+
root = described_class::BACKUP_ROOT
|
|
376
|
+
8.times do |i|
|
|
377
|
+
dir = File.join(root, "2026010#{i + 1}_000000")
|
|
378
|
+
FileUtils.mkdir_p(dir)
|
|
379
|
+
File.write(File.join(dir, 'learning.jsonl'), 'unique evidence')
|
|
380
|
+
end
|
|
381
|
+
result = described_class.prune_backups(dry_run: false, keep: 3, expire_history: true)
|
|
382
|
+
expect(result).to include(keep: 7, scope: 'global', deleted: [])
|
|
383
|
+
expect(Dir.children(root).length).to eq(8)
|
|
384
|
+
expect { described_class.send(:backup!) }.to raise_error(IOError)
|
|
385
|
+
expect(Dir.children(root).grep(/\A\d/).length).to eq(8)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
it 'publishes a verified scoped snapshot and fails closed on a copy failure' do
|
|
389
|
+
File.write(File.join(@tmp, 'learning.jsonl'), 'historical evidence')
|
|
390
|
+
FileUtils.mkdir_p(File.join(@tmp, 'logs'))
|
|
391
|
+
File.write(File.join(@tmp, 'logs', 'debug.log'), 'not migration input')
|
|
392
|
+
path = described_class.send(:backup!)
|
|
393
|
+
expect(File.binread(File.join(path, 'learning.jsonl'))).to eq('historical evidence')
|
|
394
|
+
expect(File).not_to exist(File.join(path, 'logs'))
|
|
395
|
+
manifest = JSON.parse(File.binread(File.join(path, '.snapshot-manifest.json')))
|
|
396
|
+
expect(manifest['complete']).to be(true)
|
|
397
|
+
expect(manifest['files']).to have_key('learning.jsonl')
|
|
398
|
+
allow(FileUtils).to receive(:cp_r).and_raise(IOError, 'copy failed')
|
|
399
|
+
expect { described_class.send(:backup!) }.to raise_error(IOError, 'copy failed')
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'rejects a symlinked backup root without touching its target' do
|
|
403
|
+
root = described_class::BACKUP_ROOT
|
|
404
|
+
File.symlink(@tmp, root)
|
|
405
|
+
expect { described_class.send(:backup!) }.to raise_error(IOError, /Symlink/)
|
|
406
|
+
expect(File.symlink?(root)).to be(true)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
it 'skips backups for fix mode when schema, files and vault have no drift' do
|
|
410
|
+
File.write(File.join(@tmp, '.schema'), JSON.generate(schema: described_class::SCHEMA_VERSION))
|
|
411
|
+
allow(described_class).to receive(:vault_drift).and_return({ missing: [] })
|
|
412
|
+
allow(described_class).to receive(:status).and_return({ schema_installed: described_class::SCHEMA_VERSION, schema_current: described_class::SCHEMA_VERSION, incompatible: [], pwn_version: 'test' })
|
|
413
|
+
expect(described_class).not_to receive(:backup!)
|
|
414
|
+
expect(described_class.run(fix: true, io: io)[:backup]).to be_nil
|
|
415
|
+
end
|
|
416
|
+
end
|