aim-helm-bashkit 0.1.0-x86_64-darwin
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 +7 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +128 -0
- data/Rakefile +14 -0
- data/aim-helm-bashkit.gemspec +34 -0
- data/ext/aim_helm_bashkit/extconf.rb +22 -0
- data/lib/aim-helm-bashkit.rb +3 -0
- data/lib/aim_helm_bashkit/bash.rb +119 -0
- data/lib/aim_helm_bashkit/error.rb +28 -0
- data/lib/aim_helm_bashkit/exec_result.rb +18 -0
- data/lib/aim_helm_bashkit/libbashkit.dylib +0 -0
- data/lib/aim_helm_bashkit/native.rb +126 -0
- data/lib/aim_helm_bashkit/version.rb +6 -0
- data/lib/aim_helm_bashkit/workspace.rb +95 -0
- data/lib/aim_helm_bashkit.rb +16 -0
- data/scripts/native_release.rb +57 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 929065005f97d6c20a507c95c11707d452a2fd6fabbdf100f03fafacdab380d1
|
|
4
|
+
data.tar.gz: 017b7078516f5984ad3ec78ca5965305ebaa76ccfdfd420becdaeeb9109d7003
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a190fee490c545ba0fec52fa593f495b63acd60f5a180505c63121d511591f6fa5236389064159482cb6f276defa35571cf4e61ebd8288968900e535ec142cd7
|
|
7
|
+
data.tar.gz: c6432323bbe2cf692f0c2008864241534c890571d587fae46556f63348aa1b16dc57d9205239d60d34670d5f523a39700152e6a90fc9d7f2edbfd140f6d28e85
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Accountaim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# aim-helm-bashkit
|
|
2
|
+
|
|
3
|
+
Ruby bindings for [Bashkit](https://github.com/everruns/bashkit), a sandboxed bash interpreter
|
|
4
|
+
written in Rust. Scripts run in-process against a virtual filesystem: no external shell, no host
|
|
5
|
+
filesystem, no network. `grep`, `rg`, `sed`, `awk`, `jq`, pipes, and redirects are Bashkit's own
|
|
6
|
+
implementations.
|
|
7
|
+
|
|
8
|
+
The gem calls Bashkit's C ABI through `ffi` and mirrors the core of the Python binding's `Bash` API.
|
|
9
|
+
`AimHelmBashkit::Workspace` adds a sync layer for running scripts over documents kept in a database.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "aim-helm-bashkit"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Platform gems bundle `libbashkit` for `x86_64-linux-gnu`, `aarch64-linux-gnu`, `x86_64-darwin`, and
|
|
18
|
+
`arm64-darwin` (Linux needs glibc 2.34+). The source gem downloads the same checksum-verified
|
|
19
|
+
library from the Bashkit release on install. Set `AIM_HELM_BASHKIT_LIB_PATH` to use your own build. The
|
|
20
|
+
pinned version is `AimHelmBashkit::BASHKIT_VERSION`.
|
|
21
|
+
|
|
22
|
+
## Bash
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
bash = AimHelmBashkit::Bash.new(cwd: "/work", env: { "CI" => "1" }, timeout_seconds: 30)
|
|
26
|
+
|
|
27
|
+
bash.execute("echo hello > greeting.txt")
|
|
28
|
+
result = bash.execute("cat greeting.txt | tr a-z A-Z")
|
|
29
|
+
result.stdout # => "HELLO\n"
|
|
30
|
+
result.exit_code # => 0
|
|
31
|
+
result.success? # => true
|
|
32
|
+
|
|
33
|
+
bash.execute!("exit 3") # raises AimHelmBashkit::BashError
|
|
34
|
+
bash.close
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Shell state and files persist across `execute` calls on one instance. `reset` discards both.
|
|
38
|
+
|
|
39
|
+
A script that cannot finish still returns a result, with `exit_code` 1 and a message in `error`.
|
|
40
|
+
This covers parse errors, exceeded limits, timeouts, and cancellation. ABI failures such as bad
|
|
41
|
+
configuration raise `AimHelmBashkit::Error`.
|
|
42
|
+
|
|
43
|
+
Constructor options: `profile` (`:hardened`, `:standard`, `:interactive`), `cwd`, `env`, `files`
|
|
44
|
+
(text, `{ "/path" => "content" }`), `username`, `hostname`, `timeout_seconds`,
|
|
45
|
+
`parser_timeout_seconds`, `max_commands`, `max_input_bytes`, `max_output_bytes`,
|
|
46
|
+
`readonly_filesystem`, `capture_final_env`.
|
|
47
|
+
|
|
48
|
+
### Cancellation
|
|
49
|
+
|
|
50
|
+
`execute` releases the GVL, so other threads keep running. `cancel` is safe from any thread and
|
|
51
|
+
stops the script at its next command boundary. It is sticky until `clear_cancel`.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
Thread.new { sleep 1; bash.cancel }
|
|
55
|
+
bash.execute("sleep 10").error # => "execution cancelled"
|
|
56
|
+
bash.clear_cancel
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Files
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
bash.mkdir("/data", recursive: true)
|
|
63
|
+
bash.write_file("/data/config.json", '{"debug": true}')
|
|
64
|
+
bash.read_file("/data/config.json")
|
|
65
|
+
bash.remove("/data", recursive: true)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`write_file` requires the parent directory to exist. Bashkit processes text: shell commands that
|
|
69
|
+
emit invalid UTF-8 replace those bytes with U+FFFD.
|
|
70
|
+
|
|
71
|
+
## Workspace
|
|
72
|
+
|
|
73
|
+
`Workspace` runs a script over chosen documents from a store and saves what changed. The store is
|
|
74
|
+
any object with `read(path)` (returning `nil` when missing), `write(path, content)`, and
|
|
75
|
+
`delete(path)`.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
workspace = AimHelmBashkit::Workspace.new(store, root: "/workspace")
|
|
79
|
+
|
|
80
|
+
run = workspace.execute(
|
|
81
|
+
"rg -ni 'amount|premium' inputs/schema.sql > outputs/money.txt",
|
|
82
|
+
files: ["inputs/schema.sql", "outputs/money.txt"],
|
|
83
|
+
timeout_seconds: 30,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
run.result # => AimHelmBashkit::ExecResult
|
|
87
|
+
run.written # => ["outputs/money.txt"]
|
|
88
|
+
run.deleted # => []
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`files` names every document the script may read or write. Each call:
|
|
92
|
+
|
|
93
|
+
1. Creates a fresh interpreter and copies in the listed documents that exist, at `root/<path>`.
|
|
94
|
+
Parent directories are created for every listed path.
|
|
95
|
+
2. Runs the script. Unlisted documents do not exist in the shell.
|
|
96
|
+
3. Saves listed files the script created or changed and deletes listed files it removed. Anything
|
|
97
|
+
else the script wrote is discarded.
|
|
98
|
+
|
|
99
|
+
Store paths are relative (`inputs/schema.sql`); paths containing `.`, `..`, or empty segments are
|
|
100
|
+
rejected. Files written before a failure or timeout are still saved. Writes are last-writer-wins
|
|
101
|
+
against the store.
|
|
102
|
+
|
|
103
|
+
## Examples
|
|
104
|
+
|
|
105
|
+
- [Console walkthrough](docs/console.md): a directory-backed store, reading, writing, editing, and
|
|
106
|
+
deleting files, plus known `awk` differences.
|
|
107
|
+
- [Stores](docs/stores.md): the aim-helm-rails Workspace adapter and a store over any Active Record
|
|
108
|
+
model.
|
|
109
|
+
- [Read-only execution](docs/read-only.md): let a script read documents without writing anything.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
bundle install
|
|
115
|
+
bundle exec rake native:fetch
|
|
116
|
+
bundle exec rspec
|
|
117
|
+
bundle exec rubocop
|
|
118
|
+
bin/console # IRB with `bash`, `store`, and `workspace` ready
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Releasing
|
|
122
|
+
|
|
123
|
+
Tag `vX.Y.Z` and push. The release workflow packages platform gems plus the source gem and publishes
|
|
124
|
+
to RubyGems via trusted publishing.
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core/rake_task"
|
|
4
|
+
|
|
5
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
6
|
+
|
|
7
|
+
namespace :native do
|
|
8
|
+
desc "Fetch the prebuilt libbashkit for this platform"
|
|
9
|
+
task :fetch do
|
|
10
|
+
ruby "scripts/fetch_native.rb"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
task default: :spec
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/aim_helm_bashkit/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "aim-helm-bashkit"
|
|
7
|
+
spec.version = AimHelmBashkit::VERSION
|
|
8
|
+
spec.authors = ["Accountaim"]
|
|
9
|
+
spec.summary = "Ruby bindings for Bashkit, a sandboxed bash interpreter"
|
|
10
|
+
spec.description = <<~DESC
|
|
11
|
+
Wraps the Bashkit C ABI: run bash scripts in-process against a virtual
|
|
12
|
+
filesystem, with limits and cancellation, and sync named files with a
|
|
13
|
+
host-supplied document store.
|
|
14
|
+
DESC
|
|
15
|
+
spec.homepage = "https://github.com/AccountAim/aim-helm-bashkit"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.4.0"
|
|
18
|
+
|
|
19
|
+
spec.files = Dir.chdir(__dir__) do
|
|
20
|
+
Dir["{lib,ext}/**/*", "scripts/native_release.rb", "Gemfile", "Rakefile",
|
|
21
|
+
"aim-helm-bashkit.gemspec", "README.md", "LICENSE"]
|
|
22
|
+
.grep_v(%r{\Alib/aim_helm_bashkit/libbashkit\.(so|dylib)\z})
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
spec.require_paths = ["lib"]
|
|
26
|
+
spec.extensions = ["ext/aim_helm_bashkit/extconf.rb"]
|
|
27
|
+
|
|
28
|
+
spec.add_dependency "ffi", "~> 1.15"
|
|
29
|
+
|
|
30
|
+
spec.metadata = {
|
|
31
|
+
"source_code_uri" => "https://github.com/AccountAim/aim-helm-bashkit",
|
|
32
|
+
"rubygems_mfa_required" => "true",
|
|
33
|
+
}
|
|
34
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Source installs download the official libbashkit for this platform. Platform
|
|
4
|
+
# gems bundle the library and drop this extension.
|
|
5
|
+
|
|
6
|
+
require_relative "../../scripts/native_release"
|
|
7
|
+
|
|
8
|
+
lib_dir = File.expand_path("../../lib/aim_helm_bashkit", __dir__)
|
|
9
|
+
|
|
10
|
+
if Dir[File.join(lib_dir, "libbashkit.{so,dylib}")].any?
|
|
11
|
+
puts "libbashkit already present in #{lib_dir}, skipping."
|
|
12
|
+
else
|
|
13
|
+
platform = NativeRelease.host_platform or abort <<~MSG
|
|
14
|
+
ERROR: no prebuilt libbashkit for #{RbConfig::CONFIG["host_os"]} / #{RbConfig::CONFIG["host_cpu"]}.
|
|
15
|
+
Build it from #{NativeRelease::REPO} with ./scripts/build-c-api.sh --release
|
|
16
|
+
and set AIM_HELM_BASHKIT_LIB_PATH.
|
|
17
|
+
MSG
|
|
18
|
+
NativeRelease.install(platform, lib_dir)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Required by the rubygems extension protocol.
|
|
22
|
+
File.write(File.join(__dir__, "Makefile"), "all:\ninstall:\nclean:\n")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AimHelmBashkit
|
|
6
|
+
# A sandboxed bash interpreter with an in-memory filesystem. Shell state and
|
|
7
|
+
# files persist across `execute` calls on the same instance.
|
|
8
|
+
#
|
|
9
|
+
# Calls on one instance serialize; `cancel` is safe from any thread.
|
|
10
|
+
class Bash
|
|
11
|
+
# `files` seeds text files: { "/workspace/input.txt" => "hello\n" }.
|
|
12
|
+
def initialize(
|
|
13
|
+
profile: nil, cwd: nil, env: nil, files: nil, username: nil, hostname: nil,
|
|
14
|
+
timeout_seconds: nil, parser_timeout_seconds: nil, readonly_filesystem: nil,
|
|
15
|
+
capture_final_env: nil, max_commands: nil, max_input_bytes: nil, max_output_bytes: nil
|
|
16
|
+
)
|
|
17
|
+
limits = {
|
|
18
|
+
max_commands:, max_input_bytes:, max_output_bytes:,
|
|
19
|
+
timeout_ms: timeout_seconds && (timeout_seconds * 1000).round,
|
|
20
|
+
parser_timeout_ms: parser_timeout_seconds && (parser_timeout_seconds * 1000).round
|
|
21
|
+
}.compact
|
|
22
|
+
|
|
23
|
+
@config = JSON.generate({
|
|
24
|
+
schema_version: 1, profile: profile&.to_s, cwd:, env:, files:, username:, hostname:,
|
|
25
|
+
readonly_filesystem:, capture_final_env:, limits: limits.empty? ? nil : limits
|
|
26
|
+
}.compact)
|
|
27
|
+
@handle = create
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def execute(script)
|
|
31
|
+
result_out = FFI::MemoryPointer.new(:pointer)
|
|
32
|
+
Native.call(:bashkit_execute, handle, Native.bytes(script), result_out) do |status, message|
|
|
33
|
+
return ExecResult.failure(message) if Native::FAILED_RUN.include?(status)
|
|
34
|
+
|
|
35
|
+
raise Error.new(message, code: status)
|
|
36
|
+
end
|
|
37
|
+
take_result(result_out.read_pointer)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def execute!(script)
|
|
41
|
+
execute(script).tap { raise BashError, it unless it.success? }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Aborts the running execution at its next command boundary. Sticky until
|
|
45
|
+
# `clear_cancel`: later executions fail immediately.
|
|
46
|
+
def cancel
|
|
47
|
+
Native.bashkit_cancel(handle)
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clear_cancel
|
|
52
|
+
Native.bashkit_clear_cancel(handle)
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Discards shell state and files, keeping the constructor configuration.
|
|
57
|
+
def reset
|
|
58
|
+
close
|
|
59
|
+
@handle = create
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@handle&.free
|
|
65
|
+
@handle = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_file(path)
|
|
69
|
+
buffer_out = FFI::MemoryPointer.new(:pointer)
|
|
70
|
+
Native.call(:bashkit_read_file, handle, Native.bytes(path), buffer_out)
|
|
71
|
+
Native.take_buffer(buffer_out.read_pointer)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parent directories must exist.
|
|
75
|
+
def write_file(path, content)
|
|
76
|
+
Native.call(:bashkit_write_file, handle, Native.bytes(path), Native.bytes(content))
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def mkdir(path, recursive: false)
|
|
81
|
+
Native.call(:bashkit_mkdir, handle, Native.bytes(path), recursive ? 1 : 0)
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def remove(path, recursive: false)
|
|
86
|
+
Native.call(:bashkit_remove, handle, Native.bytes(path), recursive ? 1 : 0)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def handle
|
|
93
|
+
@handle or raise Error, "bash is closed"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def create
|
|
97
|
+
bash_out = FFI::MemoryPointer.new(:pointer)
|
|
98
|
+
Native.call(:bashkit_create_json, Native.bytes(@config), bash_out)
|
|
99
|
+
FFI::AutoPointer.new(bash_out.read_pointer, Native.method(:bashkit_free))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def take_result(pointer)
|
|
103
|
+
flags = Native.bashkit_result_flags(pointer)
|
|
104
|
+
final_env = Native.read(Native.bashkit_result_final_env_json(pointer))
|
|
105
|
+
|
|
106
|
+
ExecResult.new(
|
|
107
|
+
stdout: Native.read(Native.bashkit_result_stdout(pointer)),
|
|
108
|
+
stderr: Native.read(Native.bashkit_result_stderr(pointer)),
|
|
109
|
+
exit_code: Native.bashkit_result_exit_code(pointer),
|
|
110
|
+
error: nil,
|
|
111
|
+
stdout_truncated: flags.anybits?(Native::STDOUT_TRUNCATED),
|
|
112
|
+
stderr_truncated: flags.anybits?(Native::STDERR_TRUNCATED),
|
|
113
|
+
final_env: final_env.empty? ? nil : JSON.parse(final_env),
|
|
114
|
+
)
|
|
115
|
+
ensure
|
|
116
|
+
Native.bashkit_result_free(pointer)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AimHelmBashkit
|
|
4
|
+
# `code` is the bashkit ABI status when the failure came from the native call.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :code
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, code: nil)
|
|
9
|
+
@code = code
|
|
10
|
+
super(message)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class LibraryNotFoundError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised by `execute!` when the script exits nonzero or fails to run.
|
|
17
|
+
class BashError < Error
|
|
18
|
+
attr_reader :result
|
|
19
|
+
|
|
20
|
+
def initialize(result)
|
|
21
|
+
@result = result
|
|
22
|
+
super(result.error || "exit #{result.exit_code}: #{result.stderr}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exit_code = result.exit_code
|
|
26
|
+
def stderr = result.stderr
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AimHelmBashkit
|
|
4
|
+
# `error` is set when the script could not run to completion: parse errors,
|
|
5
|
+
# exceeded limits, timeouts, and cancellation.
|
|
6
|
+
ExecResult = Data.define(
|
|
7
|
+
:stdout, :stderr, :exit_code, :error, :stdout_truncated, :stderr_truncated, :final_env
|
|
8
|
+
) do
|
|
9
|
+
def self.failure(message)
|
|
10
|
+
new(
|
|
11
|
+
stdout: +"", stderr: message, exit_code: 1, error: message,
|
|
12
|
+
stdout_truncated: false, stderr_truncated: false, final_env: nil
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def success? = exit_code.zero?
|
|
17
|
+
end
|
|
18
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module AimHelmBashkit
|
|
6
|
+
# FFI bindings to libbashkit's C ABI (crates/bashkit-capi/include/bashkit.h).
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module Native
|
|
10
|
+
extend FFI::Library
|
|
11
|
+
|
|
12
|
+
LIB_FILE = "libbashkit.#{FFI::Platform::LIBSUFFIX}".freeze
|
|
13
|
+
ABI_VERSION = 1
|
|
14
|
+
|
|
15
|
+
OK = 0
|
|
16
|
+
EXECUTION_ERROR = 4
|
|
17
|
+
IO_ERROR = 5
|
|
18
|
+
CANCELLED = 7
|
|
19
|
+
# Statuses meaning the script ran but could not finish, reported as a result.
|
|
20
|
+
FAILED_RUN = [EXECUTION_ERROR, CANCELLED].freeze
|
|
21
|
+
STDOUT_TRUNCATED = 1 << 0
|
|
22
|
+
STDERR_TRUNCATED = 1 << 1
|
|
23
|
+
|
|
24
|
+
def self.library_path
|
|
25
|
+
ENV.fetch("AIM_HELM_BASHKIT_LIB_PATH") { File.expand_path(LIB_FILE, __dir__) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
ffi_lib library_path
|
|
30
|
+
rescue LoadError => e
|
|
31
|
+
raise LibraryNotFoundError, <<~MSG
|
|
32
|
+
Could not load #{LIB_FILE}: #{e.message}
|
|
33
|
+
Run `rake native:fetch`, or set AIM_HELM_BASHKIT_LIB_PATH to the library.
|
|
34
|
+
MSG
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class Bytes < FFI::Struct
|
|
38
|
+
layout :ptr, :pointer, :len, :size_t
|
|
39
|
+
|
|
40
|
+
def self.from(string) = new.tap { it.string = string.to_s }
|
|
41
|
+
|
|
42
|
+
# The struct holds the buffer so it lives as long as the struct.
|
|
43
|
+
def string=(string)
|
|
44
|
+
@memory = FFI::MemoryPointer.new(:uint8, string.bytesize)
|
|
45
|
+
@memory.put_bytes(0, string)
|
|
46
|
+
self[:ptr] = @memory
|
|
47
|
+
self[:len] = string.bytesize
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attach_function :bashkit_abi_version, [], :uint32
|
|
52
|
+
attach_function :bashkit_version, [], Bytes.by_value
|
|
53
|
+
attach_function :bashkit_capabilities_json, [], Bytes.by_value
|
|
54
|
+
|
|
55
|
+
attach_function :bashkit_create_json, [Bytes.by_value, :pointer, :pointer], :uint32
|
|
56
|
+
attach_function :bashkit_free, [:pointer], :void
|
|
57
|
+
attach_function :bashkit_execute, [:pointer, Bytes.by_value, :pointer, :pointer], :uint32,
|
|
58
|
+
blocking: true
|
|
59
|
+
attach_function :bashkit_cancel, [:pointer], :uint32
|
|
60
|
+
attach_function :bashkit_clear_cancel, [:pointer], :uint32
|
|
61
|
+
|
|
62
|
+
attach_function :bashkit_result_exit_code, [:pointer], :int32
|
|
63
|
+
attach_function :bashkit_result_stdout, [:pointer], Bytes.by_value
|
|
64
|
+
attach_function :bashkit_result_stderr, [:pointer], Bytes.by_value
|
|
65
|
+
attach_function :bashkit_result_flags, [:pointer], :uint32
|
|
66
|
+
attach_function :bashkit_result_final_env_json, [:pointer], Bytes.by_value
|
|
67
|
+
attach_function :bashkit_result_free, [:pointer], :void
|
|
68
|
+
|
|
69
|
+
attach_function :bashkit_write_file, [:pointer, Bytes.by_value, Bytes.by_value, :pointer],
|
|
70
|
+
:uint32, blocking: true
|
|
71
|
+
attach_function :bashkit_read_file, [:pointer, Bytes.by_value, :pointer, :pointer], :uint32,
|
|
72
|
+
blocking: true
|
|
73
|
+
attach_function :bashkit_mkdir, [:pointer, Bytes.by_value, :uint32, :pointer], :uint32
|
|
74
|
+
attach_function :bashkit_remove, [:pointer, Bytes.by_value, :uint32, :pointer], :uint32
|
|
75
|
+
|
|
76
|
+
attach_function :bashkit_buffer_bytes, [:pointer], Bytes.by_value
|
|
77
|
+
attach_function :bashkit_buffer_free, [:pointer], :void
|
|
78
|
+
|
|
79
|
+
attach_function :bashkit_error_code, [:pointer], :uint32
|
|
80
|
+
attach_function :bashkit_error_message, [:pointer], Bytes.by_value
|
|
81
|
+
attach_function :bashkit_error_free, [:pointer], :void
|
|
82
|
+
|
|
83
|
+
unless (abi = bashkit_abi_version) == ABI_VERSION
|
|
84
|
+
raise Error, "#{library_path} speaks bashkit ABI #{abi}, expected #{ABI_VERSION}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# Calls a status-returning function whose trailing argument is `BashkitError **`.
|
|
89
|
+
# Yields the status and message for a non-OK status; raises when there is no block.
|
|
90
|
+
def call(function, *)
|
|
91
|
+
error_out = FFI::MemoryPointer.new(:pointer)
|
|
92
|
+
status = public_send(function, *, error_out)
|
|
93
|
+
return status if status == OK
|
|
94
|
+
|
|
95
|
+
message = take_error(error_out.read_pointer)
|
|
96
|
+
return yield(status, message) if block_given?
|
|
97
|
+
|
|
98
|
+
raise Error.new(message, code: status)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bytes(string) = Bytes.from(string)
|
|
102
|
+
|
|
103
|
+
def read(bytes)
|
|
104
|
+
return +"" if bytes[:len].zero?
|
|
105
|
+
|
|
106
|
+
bytes[:ptr].read_bytes(bytes[:len]).force_encoding(Encoding::UTF_8)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def take_buffer(pointer)
|
|
110
|
+
read(bashkit_buffer_bytes(pointer))
|
|
111
|
+
ensure
|
|
112
|
+
bashkit_buffer_free(pointer)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def take_error(pointer)
|
|
118
|
+
return "bashkit call failed" if pointer.null?
|
|
119
|
+
|
|
120
|
+
read(bashkit_error_message(pointer))
|
|
121
|
+
ensure
|
|
122
|
+
bashkit_error_free(pointer) unless pointer.null?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AimHelmBashkit
|
|
4
|
+
# Runs a script against chosen documents from a store, then saves what changed.
|
|
5
|
+
#
|
|
6
|
+
# The store is any object with `read(path) -> String | nil`, `write(path, content)`,
|
|
7
|
+
# and `delete(path)`. Store paths are relative ("inputs/schema.sql") and appear
|
|
8
|
+
# under `root` in the shell ("/workspace/inputs/schema.sql").
|
|
9
|
+
#
|
|
10
|
+
# Each `execute` uses a fresh interpreter. `files` is the only way in or out:
|
|
11
|
+
# listed documents are copied in, and afterwards each listed path is saved,
|
|
12
|
+
# deleted, or left alone to match the shell. Anything else the script writes
|
|
13
|
+
# is discarded.
|
|
14
|
+
class Workspace
|
|
15
|
+
Run = Data.define(:result, :written, :deleted)
|
|
16
|
+
|
|
17
|
+
attr_reader :store, :root
|
|
18
|
+
|
|
19
|
+
def initialize(store, root: "/workspace")
|
|
20
|
+
raise ArgumentError, "root must be absolute: #{root.inspect}" unless root.start_with?("/")
|
|
21
|
+
|
|
22
|
+
@store = store
|
|
23
|
+
@root = root.chomp("/")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def execute(script, files: [], **)
|
|
27
|
+
files = files.map { relative(it) }.uniq
|
|
28
|
+
bash = Bash.new(cwd: root, **)
|
|
29
|
+
loaded = copy_in(bash, files)
|
|
30
|
+
result = bash.execute(script)
|
|
31
|
+
written, deleted = copy_out(bash, loaded)
|
|
32
|
+
|
|
33
|
+
Run.new(result:, written:, deleted:)
|
|
34
|
+
ensure
|
|
35
|
+
bash&.close
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Parent directories exist for every listed path, so a script can write a
|
|
41
|
+
# new document without `mkdir -p`.
|
|
42
|
+
def copy_in(bash, files)
|
|
43
|
+
files.to_h do |path|
|
|
44
|
+
content = store.read(path)
|
|
45
|
+
bash.mkdir(File.dirname(absolute(path)), recursive: true)
|
|
46
|
+
bash.write_file(absolute(path), content) if content
|
|
47
|
+
[path, content]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def copy_out(bash, loaded)
|
|
52
|
+
written = []
|
|
53
|
+
deleted = []
|
|
54
|
+
|
|
55
|
+
loaded.each do |path, before|
|
|
56
|
+
after = read_shell_file(bash, path)
|
|
57
|
+
|
|
58
|
+
if after.nil?
|
|
59
|
+
next unless before
|
|
60
|
+
|
|
61
|
+
store.delete(path)
|
|
62
|
+
deleted << path
|
|
63
|
+
elsif after != before
|
|
64
|
+
store.write(path, after)
|
|
65
|
+
written << path
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
[written, deleted]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A listed path the script removed, or turned into a directory, counts as absent.
|
|
73
|
+
def read_shell_file(bash, path)
|
|
74
|
+
bash.read_file(absolute(path))
|
|
75
|
+
rescue Error => e
|
|
76
|
+
raise unless e.code == Native::IO_ERROR
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def absolute(path) = "#{root}/#{path}"
|
|
82
|
+
|
|
83
|
+
def relative(path)
|
|
84
|
+
path = path.to_s.delete_prefix("#{root}/")
|
|
85
|
+
raise ArgumentError, "invalid workspace path: #{path.inspect}" unless valid?(path)
|
|
86
|
+
|
|
87
|
+
path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def valid?(path)
|
|
91
|
+
segments = path.split("/", -1)
|
|
92
|
+
segments.any? && segments.none? { it.empty? || %w[. ..].include?(it) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "aim_helm_bashkit/version"
|
|
4
|
+
require_relative "aim_helm_bashkit/error"
|
|
5
|
+
require_relative "aim_helm_bashkit/native"
|
|
6
|
+
require_relative "aim_helm_bashkit/exec_result"
|
|
7
|
+
require_relative "aim_helm_bashkit/bash"
|
|
8
|
+
require_relative "aim_helm_bashkit/workspace"
|
|
9
|
+
|
|
10
|
+
# Ruby bindings for Bashkit, a sandboxed bash interpreter with a virtual filesystem.
|
|
11
|
+
module AimHelmBashkit
|
|
12
|
+
class << self
|
|
13
|
+
def version = Native.read(Native.bashkit_version)
|
|
14
|
+
def capabilities = JSON.parse(Native.read(Native.bashkit_capabilities_json))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open-uri"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require_relative "../lib/aim_helm_bashkit/version"
|
|
9
|
+
|
|
10
|
+
# Upstream bashkit-capi release archives, keyed by gem platform.
|
|
11
|
+
module NativeRelease
|
|
12
|
+
REPO = "https://github.com/everruns/bashkit"
|
|
13
|
+
|
|
14
|
+
TARGETS = {
|
|
15
|
+
"x86_64-linux-gnu" => "x86_64-unknown-linux-gnu",
|
|
16
|
+
"aarch64-linux-gnu" => "aarch64-unknown-linux-gnu",
|
|
17
|
+
"x86_64-darwin" => "x86_64-apple-darwin",
|
|
18
|
+
"arm64-darwin" => "aarch64-apple-darwin",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def host_platform
|
|
24
|
+
cpu = RbConfig::CONFIG["host_cpu"]
|
|
25
|
+
case RbConfig::CONFIG["host_os"]
|
|
26
|
+
when /linux/ then "#{cpu == "arm64" ? "aarch64" : cpu}-linux-gnu"
|
|
27
|
+
when /darwin/ then "#{cpu == "x86_64" ? "x86_64" : "arm64"}-darwin"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def install(platform, lib_dir)
|
|
32
|
+
target = TARGETS.fetch(platform) do
|
|
33
|
+
abort "ERROR: no prebuilt libbashkit for #{platform.inspect}. " \
|
|
34
|
+
"Known: #{TARGETS.keys.join(", ")}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
archive = "bashkit-capi-#{target}.tar.gz"
|
|
38
|
+
base = "#{REPO}/releases/download/v#{AimHelmBashkit::BASHKIT_VERSION}"
|
|
39
|
+
puts "Downloading #{base}/#{archive}..."
|
|
40
|
+
tarball = URI.parse("#{base}/#{archive}").open.read
|
|
41
|
+
expected = URI.parse("#{base}/#{archive}.sha256").open.read[/\A\h{64}/]
|
|
42
|
+
unless Digest::SHA256.hexdigest(tarball) == expected
|
|
43
|
+
abort "ERROR: checksum mismatch for #{archive}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Dir.mktmpdir do |dir|
|
|
47
|
+
File.binwrite(File.join(dir, archive), tarball)
|
|
48
|
+
system("tar", "xzf", archive, chdir: dir) or abort "ERROR: tar extraction failed"
|
|
49
|
+
|
|
50
|
+
library = Dir[File.join(dir, "*", "lib", "libbashkit.{so,dylib}")].first
|
|
51
|
+
abort "ERROR: libbashkit not found in #{archive}" unless library
|
|
52
|
+
|
|
53
|
+
FileUtils.mkdir_p(lib_dir)
|
|
54
|
+
FileUtils.cp(library, lib_dir, verbose: true)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: aim-helm-bashkit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: x86_64-darwin
|
|
6
|
+
authors:
|
|
7
|
+
- Accountaim
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ffi
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.15'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.15'
|
|
26
|
+
description: |
|
|
27
|
+
Wraps the Bashkit C ABI: run bash scripts in-process against a virtual
|
|
28
|
+
filesystem, with limits and cancellation, and sync named files with a
|
|
29
|
+
host-supplied document store.
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- aim-helm-bashkit.gemspec
|
|
39
|
+
- ext/aim_helm_bashkit/extconf.rb
|
|
40
|
+
- lib/aim-helm-bashkit.rb
|
|
41
|
+
- lib/aim_helm_bashkit.rb
|
|
42
|
+
- lib/aim_helm_bashkit/bash.rb
|
|
43
|
+
- lib/aim_helm_bashkit/error.rb
|
|
44
|
+
- lib/aim_helm_bashkit/exec_result.rb
|
|
45
|
+
- lib/aim_helm_bashkit/libbashkit.dylib
|
|
46
|
+
- lib/aim_helm_bashkit/native.rb
|
|
47
|
+
- lib/aim_helm_bashkit/version.rb
|
|
48
|
+
- lib/aim_helm_bashkit/workspace.rb
|
|
49
|
+
- scripts/native_release.rb
|
|
50
|
+
homepage: https://github.com/AccountAim/aim-helm-bashkit
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
source_code_uri: https://github.com/AccountAim/aim-helm-bashkit
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 3.4.0
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 3.6.9
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Ruby bindings for Bashkit, a sandboxed bash interpreter
|
|
73
|
+
test_files: []
|