aim-helm-bashkit 0.1.0-x86_64-linux-gnu

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: 32bf9b6f00e9697639ba952f1cac34b498afe19705f4b6dbef4bef4c49af11d9
4
+ data.tar.gz: 8285e32584e59e107a39938fb561fade3f1c773ac84d2e1a959623bed51886b9
5
+ SHA512:
6
+ metadata.gz: 488ee600eec0ce2c2823f6c0c616836d1054fff8c656c951207d5a5f212ed47255338329f8ba8a24a2ec8275b9e1c8096515dcdc1f1e1159af6aa9e90ed8a08c
7
+ data.tar.gz: 74d18599e05ab3badb3906cee3651b3fef3076e2b294b709056faed3a621a862730ed2ee7b4208aefc865de65f1bd659682f8a3bf0f737f915275234f5850735
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "irb"
8
+ gem "rake"
9
+ gem "rdoc"
10
+ gem "rspec"
11
+ gem "rubocop"
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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "aim_helm_bashkit"
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AimHelmBashkit
4
+ VERSION = "0.1.0"
5
+ BASHKIT_VERSION = "0.18.2"
6
+ 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-linux-gnu
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.so
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: []