active_storage_encryption 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +75 -0
- data/.gitignore +14 -0
- data/.ruby-version +1 -0
- data/.standard.yml +1 -0
- data/Appraisals +2 -0
- data/Gemfile +9 -0
- data/README.md +2 -0
- data/active_storage_encryption.gemspec +47 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +78 -1
- data/gemfiles/rails_8.gemfile +1 -0
- data/gemfiles/rails_8.gemfile.lock +78 -1
- data/lib/active_storage/service/encrypted_gcs_service.rb +10 -0
- data/lib/active_storage_encryption/encrypted_blob_proxy_controller.rb +1 -1
- data/lib/active_storage_encryption/encrypted_gcs_service.rb +158 -0
- data/lib/active_storage_encryption/engine.rb +4 -0
- data/lib/active_storage_encryption/overrides.rb +33 -5
- data/lib/active_storage_encryption/private_url_policy.rb +1 -1
- data/lib/active_storage_encryption/version.rb +1 -1
- data/lib/active_storage_encryption.rb +1 -0
- data/lib/generators/add_encryption_key_to_active_storage_blobs.rb.erb +9 -0
- data/lib/generators/install_generator.rb +25 -0
- data/test/dummy/app/assets/images/.keep +0 -0
- data/test/dummy/app/controllers/concerns/.keep +0 -0
- data/test/dummy/app/models/concerns/.keep +0 -0
- data/test/dummy/app/models/user.rb +5 -0
- data/test/dummy/config/environments/test.rb +2 -0
- data/test/dummy/config/storage.yml +3 -0
- data/test/dummy/db/migrate/20250428093315_create_users.rb +7 -0
- data/test/dummy/db/schema.rb +6 -3
- data/test/dummy/lib/assets/.keep +0 -0
- data/test/dummy/log/.keep +0 -0
- data/test/integration/.keep +0 -0
- data/test/lib/encrypted_gcs_service_test.rb +201 -0
- data/test/lib/encrypted_s3_service_test.rb +4 -1
- data/test/lib/overrides_test.rb +349 -0
- metadata +52 -127
- data/test/dummy/log/development.log +0 -304
- data/test/dummy/log/test.log +0 -66969
- data/test/dummy/storage/0a/mt/0amtaps713liftrtbxt9h998epz4 +0 -0
- data/test/dummy/storage/0b/93/0b93pygovuunam1a3ovzwmrbuw2x +0 -0
- data/test/dummy/storage/0m/3s/0m3s7r3nboblijr1jxlnvm3p3l4b +0 -0
- data/test/dummy/storage/0o/9s/0o9s4ctbpu757qh7ucyony0itek4 +0 -0
- data/test/dummy/storage/1e/q6/1eq646og0wazgfw7bwjqz2uem0g4 +0 -0
- data/test/dummy/storage/1n/o3/1no30cpwrm727bm6arvb7zxagdg1 +0 -0
- data/test/dummy/storage/1x/6w/1x6wsoq3pew17reztwax78lrr3hc +0 -0
- data/test/dummy/storage/28/de/28deswrv89c9f2tk7dz1l5uovd4r +0 -0
- data/test/dummy/storage/2h/sd/2hsd1mh20c6os2nzyoicfyymhwev +0 -0
- data/test/dummy/storage/2t/ni/2tnidhdk4c6cj0tnw3jiw88dgs4g +0 -0
- data/test/dummy/storage/2v/e0/2ve0555nluisy2el5cf4txzgae3j +0 -0
- data/test/dummy/storage/2z/c5/2zc5mj8g0o9l7mfnim0vs4v48xd6 +0 -0
- data/test/dummy/storage/34/xc/34xc9hk74dm9227d6mhgfcfxl4ue +0 -0
- data/test/dummy/storage/3z/0t/3z0tnve7ivrq0qyrvfhfzztjhjqs +0 -0
- data/test/dummy/storage/49/14/4914188q1dptpw4po91cp54f32bg +0 -0
- data/test/dummy/storage/4c/74/4c7412lfz0pm2ocg6u01h67bnsch +0 -0
- data/test/dummy/storage/52/qf/52qfbgjlf3gor3agsyrt09t19o55 +0 -0
- data/test/dummy/storage/57/go/57gok1uc4ebc3ugrjrje4lpe1ram +0 -0
- data/test/dummy/storage/5f/dv/5fdvt6tu1mkyajbz4hbxbw6fpt9w +0 -0
- data/test/dummy/storage/5x/b7/5xb7zzi66fi5f6yrn09pq4ogb9wo +0 -0
- data/test/dummy/storage/6m/vr/6mvr1fr5it125tm4vahjw6bv9wkz +0 -0
- data/test/dummy/storage/7b/hb/7bhbdxqn67lape1f49jqfktcei4n +0 -0
- data/test/dummy/storage/7n/4v/7n4vpm1q14y4qffc4jj78m036gtw +0 -0
- data/test/dummy/storage/7q/ku/7qkufbjwbbqwnf89uciosleixnew +0 -0
- data/test/dummy/storage/8l/5v/8l5vb4o02hx46s5qohfn5to945p3 +0 -0
- data/test/dummy/storage/8q/pu/8qpun3f8vzl7auxajvqyq8f48ngw +0 -0
- data/test/dummy/storage/8w/ag/8wag4ptmox207h7mobamk0tcebwx +0 -0
- data/test/dummy/storage/8w/v8/8wv8lrhsw4s2r6guh1csd3jd89ii +0 -0
- data/test/dummy/storage/9b/c6/9bc6wlpfnqdywpnxgeoin3w9b5ch +0 -0
- data/test/dummy/storage/9l/wk/9lwkt21k5iburdaitbwliw7krtwt +0 -0
- data/test/dummy/storage/9p/0v/9p0vgfw3l2854k7so3rp33rmyh7p +0 -0
- data/test/dummy/storage/9r/sy/9rsya3r6syft34qz24g1h4u4qq44 +0 -0
- data/test/dummy/storage/9s/es/9seslusr46xjf3mfzq10hkp13kc1 +0 -0
- data/test/dummy/storage/9t/nv/9tnvn5v52fkvurpgszf4gco78t5h +0 -0
- data/test/dummy/storage/9u/to/9utokgxyu6xyovandu7pjhogoaqp +0 -0
- data/test/dummy/storage/9w/a4/9wa4c20p4dvm1cd5thnv9f7ei13w +0 -0
- data/test/dummy/storage/at/kg/atkgs5gwz2xdv9lvqftsg6p7gcpu +0 -0
- data/test/dummy/storage/at/qo/atqomgf3rpb2f6e1tq1yn2xqzojv +0 -0
- data/test/dummy/storage/ba/lq/balqtije6kf82ht4lr70ajaae9kc +0 -0
- data/test/dummy/storage/bf/i1/bfi1ij9rygr6lkx1r0lhgi8o5smx +0 -0
- data/test/dummy/storage/bg/ye/bgyenotrv3aj6lk88edwv0c41pfj +0 -0
- data/test/dummy/storage/bu/xe/buxed4b1l78kcax53fa37awm9ywk +0 -0
- data/test/dummy/storage/d2/c1/d2c11nhikb474oq3q7so0xbhukvj +0 -0
- data/test/dummy/storage/development.sqlite3 +0 -0
- data/test/dummy/storage/dk/hy/dkhybxn2o27a8xgvfhsxpgqxa1zf +0 -0
- data/test/dummy/storage/e7/2n/e72nz5cz3wf6qvh4dw4qfnw6ucog +0 -0
- data/test/dummy/storage/eo/4q/eo4qn68m7al0ehhe0s23ycuzkjto +0 -0
- data/test/dummy/storage/ew/8s/ew8sejdsx8ddmrzkvfa37ebz1ts1 +0 -0
- data/test/dummy/storage/f8/q1/f8q1kpg2tou8ru0afj8d2gy6ym5p +0 -0
- data/test/dummy/storage/fr/55/fr558uhp1k93jzhb4butyi2ry51t +0 -0
- data/test/dummy/storage/g4/nh/g4nhx1zxbeiegqpgn8ppsl1yhm0t +0 -0
- data/test/dummy/storage/gg/r5/ggr51egxhqfh4w5eluzs47qceb76 +0 -0
- data/test/dummy/storage/gh/ua/ghuaagralqmjy8rkbwmuv3010lvs +0 -0
- data/test/dummy/storage/gx/uh/gxuhmf52ufli3m7ng8irp8ghxa1v +0 -0
- data/test/dummy/storage/h0/m1/h0m1emy251xus1d9qh6u25dzy18o +0 -0
- data/test/dummy/storage/hh/kc/hhkc2q8paptyvhw2m5hlwylhtfo5 +0 -0
- data/test/dummy/storage/hq/0q/hq0q04kr6qzrp0qaee8rehcp2tzx +0 -0
- data/test/dummy/storage/ii/g1/iig1ge3fsjitai4g2fkq4qt369wh +0 -0
- data/test/dummy/storage/io/f0/iof0mv7w8qjd6m826g52pzyxedet +0 -0
- data/test/dummy/storage/jk/2i/jk2ifmx6ac35ubk3esufnm6bn1m1 +0 -0
- data/test/dummy/storage/jw/4t/jw4trdeyfkw3j8z70xcnr9a7gqe5 +0 -0
- data/test/dummy/storage/ke/k2/kek24leksglm1rs2a78mfmot0p3s +0 -0
- data/test/dummy/storage/kh/6d/kh6doaxxwxiyes0yqz2dmmpajkzv +0 -0
- data/test/dummy/storage/kj/7n/kj7nookjhisagd80z8hlv3wn50am +0 -0
- data/test/dummy/storage/kq/lf/kqlf5udtrgrk4v55qodxyt6i68p8 +0 -0
- data/test/dummy/storage/ky/33/ky334jbo8eb08pj9qbe919iz91mh +0 -0
- data/test/dummy/storage/lt/zw/ltzw4lur2bheit1273ogpfzhv7j1 +0 -0
- data/test/dummy/storage/m2/ve/m2vejmyttn1ium81dopppom6vum6 +0 -0
- data/test/dummy/storage/m8/d4/m8d4r9iauedq8wlpvnx1f3ou0jwg +0 -0
- data/test/dummy/storage/m9/ee/m9eetioklzatyff94gq0vn1cga1n +0 -0
- data/test/dummy/storage/ma/v0/mav084zvmyoh1a8i7dcwqy2aaoi9 +0 -0
- data/test/dummy/storage/mg/pa/mgpauiu02i28j3poef65k3q0gfpw +0 -0
- data/test/dummy/storage/mm/8g/mm8gp5evncb1ol1lj2jlmra2ixij +0 -0
- data/test/dummy/storage/mm/d2/mmd21x8c1amgnidzw0wowiwug4g3 +0 -0
- data/test/dummy/storage/n2/qr/n2qro0y9heko9cwxlf10wiqiipsw +0 -0
- data/test/dummy/storage/n8/b7/n8b7b7qgu6jtw577dnn10jrrmszs +0 -0
- data/test/dummy/storage/n8/p2/n8p2ine0qqhphn09kqtxco4y7g0a +0 -0
- data/test/dummy/storage/nk/vh/nkvhgk7snpdy2ak6k02htxx9swp7 +0 -0
- data/test/dummy/storage/nn/s0/nns0nggo0x645ytco52adnsi4myp +0 -0
- data/test/dummy/storage/nu/kz/nukzl7cckkzh68i7kyjkm9mzw7c0 +0 -0
- data/test/dummy/storage/nv/8v/nv8vyoghcde1yr1bjpsw4327qt7s +0 -0
- data/test/dummy/storage/of/on/ofonhf1gs26k3dpj6o7b0ktzfowh +0 -0
- data/test/dummy/storage/pl/pf/plpfs59hvdoogj9gdweqta36csqn +0 -0
- data/test/dummy/storage/q5/g5/q5g55ekmscu10pzfw6q4syigt81g +0 -0
- data/test/dummy/storage/q5/kc/q5kcr9twyb9v4mh31pay0t7nkuwu +0 -0
- data/test/dummy/storage/qa/xd/qaxdngi74r52ahqg1pz8hjddeajc +0 -0
- data/test/dummy/storage/r7/5v/r75vadn34ak53vinylgnfdl1s8rt +0 -0
- data/test/dummy/storage/rj/rg/rjrghnyzyvxpkjw1a57mrloz72x1 +0 -0
- data/test/dummy/storage/se/h7/seh7eorfoanpp6de62pubv7kyu1a +0 -0
- data/test/dummy/storage/sj/i1/sji1oj12soz2fcjcoz0gejvzo8to +0 -0
- data/test/dummy/storage/sn/2r/sn2rku9thay4hbcbt926an69maku +0 -0
- data/test/dummy/storage/sw/jm/swjmbmxou3tnarcirxc6gdycxh91 +0 -0
- data/test/dummy/storage/sz/mq/szmqlydvpgqaw7p3v0wh444wtcif +0 -0
- data/test/dummy/storage/test.sqlite3 +0 -0
- data/test/dummy/storage/tg/by/tgbyrdvg94ivhhy2z59e8l9fod10 +0 -0
- data/test/dummy/storage/u5/vm/u5vmz08tuayqggd436et8fiaeml1 +0 -0
- data/test/dummy/storage/u6/pf/u6pf4yky0vbmvid3fa3lm4lre68g +0 -0
- data/test/dummy/storage/ub/ql/ubqlmlilt8ujgdpngcm1zae41kgy +0 -0
- data/test/dummy/storage/un/29/un29e9khqism72ag27ojccmn5sds +0 -0
- data/test/dummy/storage/ux/ns/uxnsvuk4rr1p67n1oq6tmraz0gaw +0 -0
- data/test/dummy/storage/v1/qo/v1qor0zxg3lctk9mbwyos3oag9gj +0 -0
- data/test/dummy/storage/v8/ok/v8okmd7374w1obna13a7anllx2vu +0 -0
- data/test/dummy/storage/vd/tf/vdtfmz2ctis3dr1r35do9bow2xj5 +0 -0
- data/test/dummy/storage/vo/dg/vodgq1inccnujjt3auber7tt8w8o +0 -0
- data/test/dummy/storage/vp/oe/vpoeiq00tf9pk0jcjlccomkju1zc +0 -0
- data/test/dummy/storage/vu/kg/vukgoj6qf96bhealui2yaeyn4n72 +0 -0
- data/test/dummy/storage/w7/2z/w72zoqu7v6v6jp0tpy671dcbvpow +0 -0
- data/test/dummy/storage/wa/3f/wa3fncsnozc6n4xfu32gw34geqcd +0 -0
- data/test/dummy/storage/wy/ix/wyixbqx3f6a4agb8bjhrtpblpaua +0 -0
- data/test/dummy/storage/xd/st/xdsttma3tqt7mex0vhp1vsm3dq16 +0 -0
- data/test/dummy/storage/xv/ej/xvejm2e064bnpunx3nmktaqs0x90 +0 -0
- data/test/dummy/storage/xx/py/xxpyyodssq2xmp57qrtvuw0wchwk +0 -0
- data/test/dummy/storage/xz/ik/xzikejc5sohi3zexa93s9xmg4jst +0 -0
- data/test/dummy/storage/y4/g8/y4g8teo86blcv0zysa2d2jawvk6i +0 -0
- data/test/dummy/storage/y9/58/y958xli6aoktx1ehuyjc1k8dcbzv +0 -0
- data/test/dummy/storage/yj/lw/yjlw8bf70iujb16deja8ae43rqbc +0 -0
- data/test/dummy/storage/z3/qy/z3qyb9avbucwhxa8909rpfued0y5 +0 -0
- data/test/dummy/storage/zr/wu/zrwudcg4kgo7r0jemszuzok8grqp +0 -0
- data/test/dummy/tmp/local_secret.txt +0 -1
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_storage/service/gcs_service"
|
4
|
+
require "google/cloud/storage/service"
|
5
|
+
|
6
|
+
class ActiveStorageEncryption::EncryptedGCSService < ActiveStorage::Service::GCSService
|
7
|
+
include ActiveStorageEncryption::PrivateUrlPolicy
|
8
|
+
GCS_ENCRYPTION_KEY_LENGTH_BYTES = 32 # google wants to get a 32 byte key
|
9
|
+
|
10
|
+
def encrypted? = true
|
11
|
+
|
12
|
+
def public? = false
|
13
|
+
|
14
|
+
def service_name
|
15
|
+
# ActiveStorage::Service::DiskService => Disk
|
16
|
+
# Overridden because in Rails 8 this is "self.class.name.split("::").third.remove("Service")"
|
17
|
+
self.class.name.split("::").last.remove("Service")
|
18
|
+
end
|
19
|
+
|
20
|
+
def upload(key, io, encryption_key: nil, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {})
|
21
|
+
instrument :upload, key: key, checksum: checksum do
|
22
|
+
# GCS's signed URLs don't include params such as response-content-type response-content_disposition
|
23
|
+
# in the signature, which means an attacker can modify them and bypass our effort to force these to
|
24
|
+
# binary and attachment when the file's content type requires it. The only way to force them is to
|
25
|
+
# store them as object's metadata.
|
26
|
+
content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
|
27
|
+
bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata, encryption_key: derive_service_encryption_key(encryption_key))
|
28
|
+
rescue Google::Cloud::InvalidArgumentError => e
|
29
|
+
raise ActiveStorage::IntegrityError, e
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def url_for_direct_upload(key, expires_in:, checksum:, encryption_key:, content_type: nil, custom_metadata: {}, filename: nil, **)
|
34
|
+
instrument :url, key: key do |payload|
|
35
|
+
headers = headers_for_direct_upload(key, checksum:, encryption_key:, content_type:, filename:, custom_metadata:)
|
36
|
+
|
37
|
+
version = :v4
|
38
|
+
|
39
|
+
args = {
|
40
|
+
content_md5: checksum,
|
41
|
+
expires: expires_in,
|
42
|
+
headers: headers,
|
43
|
+
method: "PUT",
|
44
|
+
version: version
|
45
|
+
}
|
46
|
+
|
47
|
+
if @config[:iam]
|
48
|
+
args[:issuer] = issuer
|
49
|
+
args[:signer] = signer
|
50
|
+
end
|
51
|
+
|
52
|
+
generated_url = bucket.signed_url(key, **args)
|
53
|
+
|
54
|
+
payload[:url] = generated_url
|
55
|
+
|
56
|
+
generated_url
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def headers_for_direct_upload(key, checksum:, encryption_key:, filename: nil, disposition: nil, content_type: nil, custom_metadata: {}, **)
|
61
|
+
headers = {
|
62
|
+
"Content-Type" => content_type,
|
63
|
+
"Content-MD5" => checksum, # Not strictly required, but it ensures the file bytes we upload match what we want. This way google will error when we upload garbage.
|
64
|
+
**gcs_encryption_key_headers(derive_service_encryption_key(encryption_key)),
|
65
|
+
**custom_metadata_headers(custom_metadata)
|
66
|
+
}
|
67
|
+
headers["Content-Disposition"] = content_disposition_with(type: disposition, filename: filename) if filename
|
68
|
+
|
69
|
+
if @config[:cache_control].present?
|
70
|
+
headers["Cache-Control"] = @config[:cache_control]
|
71
|
+
end
|
72
|
+
headers
|
73
|
+
end
|
74
|
+
|
75
|
+
def download(key, encryption_key: nil, &block)
|
76
|
+
if block_given?
|
77
|
+
instrument :streaming_download, key: key do
|
78
|
+
stream(key, encryption_key: encryption_key, &block)
|
79
|
+
end
|
80
|
+
else
|
81
|
+
instrument :download, key: key do
|
82
|
+
file_for(key).download(encryption_key: derive_service_encryption_key(encryption_key)).string
|
83
|
+
rescue Google::Cloud::NotFoundError => e
|
84
|
+
raise ActiveStorage::FileNotFoundError, e
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def download_chunk(key, range, encryption_key: nil)
|
90
|
+
instrument :download_chunk, key: key, range: range do
|
91
|
+
file_for(key).download(range: range, encryption_key: derive_service_encryption_key(encryption_key)).string
|
92
|
+
rescue Google::Cloud::NotFoundError => e
|
93
|
+
raise ActiveStorage::FileNotFoundError, e
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Reads the file for the given key in chunks, yielding each to the block.
|
98
|
+
def stream(key, encryption_key: nil)
|
99
|
+
file = file_for(key, skip_lookup: false)
|
100
|
+
|
101
|
+
chunk_size = 5.megabytes
|
102
|
+
offset = 0
|
103
|
+
|
104
|
+
raise ActiveStorage::FileNotFoundError unless file.present?
|
105
|
+
|
106
|
+
while offset < file.size
|
107
|
+
yield file.download(range: offset..(offset + chunk_size - 1), encryption_key: derive_service_encryption_key(encryption_key)).string
|
108
|
+
offset += chunk_size
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def compose(source_keys, destination_key, encryption_key:, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
|
113
|
+
# Because we will always have a different encryption_key on a blob when created and google requires us to have the same encryption_keys on all source blobs
|
114
|
+
# we need to work this out a bit more. For now we don't need this and thus won't support it in this service.
|
115
|
+
raise NotImplementedError, "Currently composing files is not supported"
|
116
|
+
end
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
def private_url(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, **remaining_options_for_streaming_url)
|
121
|
+
if private_url_policy == :require_headers
|
122
|
+
args = {
|
123
|
+
expires: expires_in,
|
124
|
+
query: {
|
125
|
+
"response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
|
126
|
+
"response-content-type" => content_type
|
127
|
+
},
|
128
|
+
headers: gcs_encryption_key_headers(derive_service_encryption_key(encryption_key))
|
129
|
+
}
|
130
|
+
|
131
|
+
if @config[:iam]
|
132
|
+
args[:issuer] = issuer
|
133
|
+
args[:signer] = signer
|
134
|
+
end
|
135
|
+
|
136
|
+
file_for(key).signed_url(**args, version: :v4)
|
137
|
+
else
|
138
|
+
private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, **remaining_options_for_streaming_url)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def public_url(key, filename:, encryption_key:, content_type: nil, disposition: :inline, **)
|
143
|
+
raise "Public URL's are disabled for this service"
|
144
|
+
end
|
145
|
+
|
146
|
+
def gcs_encryption_key_headers(key)
|
147
|
+
{
|
148
|
+
"x-goog-encryption-algorithm" => "AES256",
|
149
|
+
"x-goog-encryption-key" => Base64.strict_encode64(key),
|
150
|
+
"x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(key)
|
151
|
+
}
|
152
|
+
end
|
153
|
+
|
154
|
+
def derive_service_encryption_key(blob_encryption_key)
|
155
|
+
raise ArgumentError, "The blob encryption_key must be at least #{GCS_ENCRYPTION_KEY_LENGTH_BYTES} bytes long" unless blob_encryption_key.bytesize >= GCS_ENCRYPTION_KEY_LENGTH_BYTES
|
156
|
+
blob_encryption_key[0...GCS_ENCRYPTION_KEY_LENGTH_BYTES]
|
157
|
+
end
|
158
|
+
end
|
@@ -2,6 +2,9 @@
|
|
2
2
|
|
3
3
|
module ActiveStorageEncryption
|
4
4
|
module Overrides
|
5
|
+
class EncryptionKeyMissingError < StandardError
|
6
|
+
end
|
7
|
+
|
5
8
|
module EncryptedBlobClassMethods
|
6
9
|
def self.included base
|
7
10
|
base.class_eval do
|
@@ -54,7 +57,7 @@ module ActiveStorageEncryption
|
|
54
57
|
content_type ||= blobs.pluck(:content_type).compact.first
|
55
58
|
|
56
59
|
new(key: key, filename: filename, content_type: content_type, metadata: metadata, byte_size: blobs.sum(&:byte_size), service_name:, encryption_key:).tap do |combined_blob|
|
57
|
-
combined_blob.compose(blobs.pluck(:key))
|
60
|
+
combined_blob.compose(blobs.pluck(:key), source_encryption_keys: blobs.pluck(:encryption_key))
|
58
61
|
combined_blob.save!
|
59
62
|
end
|
60
63
|
end
|
@@ -70,7 +73,8 @@ module ActiveStorageEncryption
|
|
70
73
|
|
71
74
|
def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
|
72
75
|
if service_encrypted?
|
73
|
-
|
76
|
+
ensure_encryption_key_set!
|
77
|
+
|
74
78
|
service.url_for_direct_upload(key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata, encryption_key: encryption_key)
|
75
79
|
else
|
76
80
|
super
|
@@ -78,6 +82,8 @@ module ActiveStorageEncryption
|
|
78
82
|
end
|
79
83
|
|
80
84
|
def open(tmpdir: nil, &block)
|
85
|
+
ensure_encryption_key_set! if service_encrypted?
|
86
|
+
|
81
87
|
service.open(
|
82
88
|
key,
|
83
89
|
encryption_key: encryption_key,
|
@@ -91,6 +97,8 @@ module ActiveStorageEncryption
|
|
91
97
|
|
92
98
|
def service_headers_for_direct_upload
|
93
99
|
if service_encrypted?
|
100
|
+
ensure_encryption_key_set!
|
101
|
+
|
94
102
|
service.headers_for_direct_upload(key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata, encryption_key: encryption_key)
|
95
103
|
else
|
96
104
|
super
|
@@ -99,6 +107,8 @@ module ActiveStorageEncryption
|
|
99
107
|
|
100
108
|
def upload_without_unfurling(io)
|
101
109
|
if service_encrypted?
|
110
|
+
ensure_encryption_key_set!
|
111
|
+
|
102
112
|
service.upload(key, io, checksum: checksum, encryption_key: encryption_key, **service_metadata)
|
103
113
|
else
|
104
114
|
super
|
@@ -109,6 +119,8 @@ module ActiveStorageEncryption
|
|
109
119
|
# That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
|
110
120
|
def download(&block)
|
111
121
|
if service_encrypted?
|
122
|
+
ensure_encryption_key_set!
|
123
|
+
|
112
124
|
service.download(key, encryption_key: encryption_key, &block)
|
113
125
|
else
|
114
126
|
super
|
@@ -117,23 +129,29 @@ module ActiveStorageEncryption
|
|
117
129
|
|
118
130
|
def download_chunk(range)
|
119
131
|
if service_encrypted?
|
132
|
+
ensure_encryption_key_set!
|
133
|
+
|
120
134
|
service.download_chunk(key, range, encryption_key: encryption_key)
|
121
135
|
else
|
122
136
|
super
|
123
137
|
end
|
124
138
|
end
|
125
139
|
|
126
|
-
def compose(keys)
|
140
|
+
def compose(keys, source_encryption_keys: [])
|
127
141
|
if service_encrypted?
|
142
|
+
ensure_encryption_key_set!
|
143
|
+
|
128
144
|
self.composed = true
|
129
|
-
service.compose(keys, key, encryption_key: encryption_key, **service_metadata)
|
145
|
+
service.compose(keys, key, encryption_key: encryption_key, source_encryption_keys: source_encryption_keys, **service_metadata)
|
130
146
|
else
|
131
|
-
super
|
147
|
+
super(keys)
|
132
148
|
end
|
133
149
|
end
|
134
150
|
|
135
151
|
def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
|
136
152
|
if service_encrypted?
|
153
|
+
ensure_encryption_key_set!
|
154
|
+
|
137
155
|
service.url(
|
138
156
|
key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
|
139
157
|
encryption_key: encryption_key,
|
@@ -156,6 +174,12 @@ module ActiveStorageEncryption
|
|
156
174
|
end
|
157
175
|
super
|
158
176
|
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
def ensure_encryption_key_set!
|
181
|
+
raise EncryptionKeyMissingError, "Encryption key must be present" unless encryption_key.present?
|
182
|
+
end
|
159
183
|
end
|
160
184
|
|
161
185
|
module BlobIdentifiableInstanceMethods
|
@@ -178,6 +202,8 @@ module ActiveStorageEncryption
|
|
178
202
|
|
179
203
|
module DownloaderInstanceMethods
|
180
204
|
def open(key, encryption_key: nil, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil, &blk)
|
205
|
+
raise EncryptionKeyMissingError, "An encryption key must be supplied when using an encrypted service" if !encryption_key && service.respond_to?(:encrypted?) && service.encrypted?
|
206
|
+
|
181
207
|
open_tempfile(name, tmpdir) do |file|
|
182
208
|
download(key, file, encryption_key: encryption_key)
|
183
209
|
verify_integrity_of(file, checksum: checksum) if verify
|
@@ -189,6 +215,8 @@ module ActiveStorageEncryption
|
|
189
215
|
|
190
216
|
def download(key, file, encryption_key: nil)
|
191
217
|
if service.respond_to?(:encrypted?) && service.encrypted?
|
218
|
+
raise "An encryption key must be supplied when using an encrypted service" unless encryption_key
|
219
|
+
|
192
220
|
file.binmode
|
193
221
|
service.download(key, encryption_key: encryption_key) { |chunk| file.write(chunk) }
|
194
222
|
file.flush
|
@@ -18,7 +18,7 @@ module ActiveStorageEncryption::PrivateUrlPolicy
|
|
18
18
|
@private_url_policy
|
19
19
|
end
|
20
20
|
|
21
|
-
def private_url_for_streaming_via_controller(key,
|
21
|
+
def private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, blob_byte_size:)
|
22
22
|
if private_url_policy == :disable
|
23
23
|
raise ActiveStorageEncryption::StreamingDisabled, <<~EOS
|
24
24
|
Requested a signed GET URL for #{key.inspect} on service #{name}. This service
|
@@ -10,6 +10,7 @@ module ActiveStorageEncryption
|
|
10
10
|
autoload :EncryptedDiskService, __dir__ + "/active_storage_encryption/encrypted_disk_service.rb"
|
11
11
|
autoload :EncryptedMirrorService, __dir__ + "/active_storage_encryption/encrypted_mirror_service.rb"
|
12
12
|
autoload :EncryptedS3Service, __dir__ + "/active_storage_encryption/encrypted_s3_service.rb"
|
13
|
+
autoload :EncryptedGCSService, __dir__ + "/active_storage_encryption/encrypted_gcs_service.rb"
|
13
14
|
autoload :Overrides, __dir__ + "/active_storage_encryption/overrides.rb"
|
14
15
|
|
15
16
|
class IncorrectEncryptionKey < ArgumentError
|
@@ -0,0 +1,9 @@
|
|
1
|
+
class AddEncryptionKeyToActiveStorageBlobs < ActiveRecord::Migration[7.2]
|
2
|
+
def change
|
3
|
+
# You _must_ use attribute encryption for this column. Rails uses base64 and JSON encoding
|
4
|
+
# for encrypted attributes, so they can be stored as a string. The "raw" encryption key
|
5
|
+
# that active_storage_encryption will generate and assign to the Blob is going to be
|
6
|
+
# binary, however.
|
7
|
+
add_column :active_storage_blobs, :encryption_key, :string, if_not_exists: true
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module ActiveStorageEncryption
|
7
|
+
# The generator is used to install ActiveStorageEncryption. It adds the `encryption_key`
|
8
|
+
# column to ActiveStorage::Blob.
|
9
|
+
# Run it with `bin/rails g active_storage_encryption:install` in your console.
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
11
|
+
include ActiveRecord::Generators::Migration
|
12
|
+
|
13
|
+
source_paths << File.join(File.dirname(__FILE__, 2))
|
14
|
+
|
15
|
+
# Generates monolithic migration file that contains all database changes.
|
16
|
+
def create_migration_file
|
17
|
+
# Adding a new migration to the gem is then just adding a file.
|
18
|
+
migration_file_paths_in_order = Dir.glob(__dir__ + "/*.rb.erb").sort
|
19
|
+
migration_file_paths_in_order.each do |migration_template_path|
|
20
|
+
untemplated_migration_filename = File.basename(migration_template_path).gsub(/\.erb$/, "")
|
21
|
+
migration_template(migration_template_path, File.join(db_migrate_path, untemplated_migration_filename))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
File without changes
|
File without changes
|
File without changes
|
@@ -42,6 +42,8 @@ Rails.application.configure do
|
|
42
42
|
# Tell Active Support which deprecation messages to disallow.
|
43
43
|
config.active_support.disallowed_deprecation_warnings = []
|
44
44
|
|
45
|
+
config.active_storage.service = :test
|
46
|
+
|
45
47
|
# Raises error for missing translations.
|
46
48
|
# config.i18n.raise_on_missing_translations = true
|
47
49
|
|
data/test/dummy/db/schema.rb
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
1
|
# This file is auto-generated from the current state of the database. Instead
|
4
2
|
# of editing this file, please use the migrations feature of Active Record to
|
5
3
|
# incrementally modify your database, and then regenerate this schema definition.
|
@@ -12,7 +10,7 @@
|
|
12
10
|
#
|
13
11
|
# It's strongly recommended that you check this file into your version control system.
|
14
12
|
|
15
|
-
ActiveRecord::Schema[7.2].define(version:
|
13
|
+
ActiveRecord::Schema[7.2].define(version: 2025_04_28_093315) do
|
16
14
|
create_table "active_storage_attachments", force: :cascade do |t|
|
17
15
|
t.string "name", null: false
|
18
16
|
t.string "record_type", null: false
|
@@ -42,6 +40,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_04_023853) do
|
|
42
40
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
43
41
|
end
|
44
42
|
|
43
|
+
create_table "users", force: :cascade do |t|
|
44
|
+
t.datetime "created_at", null: false
|
45
|
+
t.datetime "updated_at", null: false
|
46
|
+
end
|
47
|
+
|
45
48
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
46
49
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
47
50
|
end
|
File without changes
|
File without changes
|
File without changes
|
@@ -0,0 +1,201 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
|
5
|
+
class ActiveStorageEncryption::EncryptedGCSServiceTest < ActiveSupport::TestCase
|
6
|
+
def config
|
7
|
+
{
|
8
|
+
project_id: "sandbox-ci-25b8",
|
9
|
+
bucket: "sandbox-ci-testing-secure-documents",
|
10
|
+
private_url_policy: "stream"
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
setup do
|
15
|
+
if ENV["GOOGLE_APPLICATION_CREDENTIALS"].blank?
|
16
|
+
skip "You need GOOGLE_APPLICATION_CREDENTIALS set in your env and it needs to point to the JSON keyfile for GCS"
|
17
|
+
end
|
18
|
+
|
19
|
+
@textfile = StringIO.new("Secure document that needs to be stored encrypted.")
|
20
|
+
@textfile2 = StringIO.new("While being neatly organized all in a days work aat the job.")
|
21
|
+
@service = ActiveStorageEncryption::EncryptedGCSService.new(**config)
|
22
|
+
@service.name = "encrypted_gcs_service"
|
23
|
+
|
24
|
+
@encryption_key = ActiveStorage::Blob.generate_random_encryption_key
|
25
|
+
@gcs_key_length_range = (0...ActiveStorageEncryption::EncryptedGCSService::GCS_ENCRYPTION_KEY_LENGTH_BYTES) # 32 bytes
|
26
|
+
end
|
27
|
+
|
28
|
+
def run_id
|
29
|
+
# We use a shared GCS bucket, and multiple runs of the test suite may write into it at the same time.
|
30
|
+
# To prevent clobbering and conflicts, assign a "test run ID" and mix it into the object keys. Keep that
|
31
|
+
# value stable across the test suite.
|
32
|
+
@test_suite_run_id ||= SecureRandom.base36(10)
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_encrypted_question_method
|
36
|
+
assert @service.encrypted?
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_forbids_private_urls_with_disabled_policy
|
40
|
+
@service.private_url_policy = :disable
|
41
|
+
|
42
|
+
rng = Random.new(Minitest.seed)
|
43
|
+
key = "#{run_id}-streamed-key-#{rng.hex(4)}"
|
44
|
+
encryption_key = Random.bytes(68)
|
45
|
+
plaintext_upload_bytes = rng.bytes(425)
|
46
|
+
@service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
|
47
|
+
|
48
|
+
# ActiveStorage wraps the passed filename in a wrapper thingy
|
49
|
+
filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
|
50
|
+
|
51
|
+
assert_raises(ActiveStorageEncryption::StreamingDisabled) do
|
52
|
+
@service.url(key, filename: filename_with_sanitization, blob_byte_size: plaintext_upload_bytes.bytesize, content_type: "binary/octet-stream", disposition: "inline", encryption_key:, expires_in: 10.seconds)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def test_exists
|
57
|
+
rng = Random.new(Minitest.seed)
|
58
|
+
|
59
|
+
key = "#{run_id}-encrypted-exists-key-#{rng.hex(4)}"
|
60
|
+
encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
|
61
|
+
plaintext_upload_bytes = rng.bytes(1024)
|
62
|
+
|
63
|
+
assert_nothing_raised { @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) }
|
64
|
+
refute @service.exist?(key + "-definitely-not-present")
|
65
|
+
assert @service.exist?(key)
|
66
|
+
end
|
67
|
+
|
68
|
+
def test_generates_private_streaming_urls_with_streaming_policy
|
69
|
+
@service.private_url_policy = :stream
|
70
|
+
|
71
|
+
rng = Random.new(Minitest.seed)
|
72
|
+
key = "#{run_id}-streamed-key-#{rng.hex(4)}"
|
73
|
+
encryption_key = Random.bytes(68)
|
74
|
+
plaintext_upload_bytes = rng.bytes(425)
|
75
|
+
@service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
|
76
|
+
|
77
|
+
# The streaming URL generation uses Rails routing, so it needs
|
78
|
+
# ActiveStorage::Current.url_options to be set
|
79
|
+
# We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
|
80
|
+
# see https://stackoverflow.com/a/60573259/153886
|
81
|
+
ActiveStorage::Current.url_options = {
|
82
|
+
host: "www.example.com",
|
83
|
+
protocol: "https"
|
84
|
+
}
|
85
|
+
|
86
|
+
# ActiveStorage wraps the passed filename in a wrapper thingy
|
87
|
+
filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
|
88
|
+
url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize,
|
89
|
+
filename: filename_with_sanitization, content_type: "binary/octet-stream",
|
90
|
+
disposition: "inline", encryption_key:, expires_in: 10.seconds)
|
91
|
+
assert url.include?("/active-storage-encryption/blob/")
|
92
|
+
end
|
93
|
+
|
94
|
+
def test_generates_private_urls_with_require_headers_policy
|
95
|
+
@service.private_url_policy = :require_headers
|
96
|
+
|
97
|
+
rng = Random.new(Minitest.seed)
|
98
|
+
key = "#{run_id}-streamed-key-#{rng.hex(4)}"
|
99
|
+
encryption_key = Random.bytes(68)
|
100
|
+
plaintext_upload_bytes = rng.bytes(425)
|
101
|
+
@service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
|
102
|
+
|
103
|
+
# ActiveStorage wraps the passed filename in a wrapper thingy
|
104
|
+
filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
|
105
|
+
url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize,
|
106
|
+
filename: filename_with_sanitization, content_type: "binary/octet-stream",
|
107
|
+
disposition: "inline", encryption_key:, expires_in: 240.seconds)
|
108
|
+
|
109
|
+
query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h
|
110
|
+
|
111
|
+
# Downcased header names for this test since that's what we get back from signing process.
|
112
|
+
expected_headers = ["x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"]
|
113
|
+
signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";")
|
114
|
+
assert expected_headers.all? { |header| header.in?(signed_headers) }
|
115
|
+
|
116
|
+
uri = URI(url)
|
117
|
+
req = Net::HTTP::Get.new(uri)
|
118
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http|
|
119
|
+
http.request(req)
|
120
|
+
}
|
121
|
+
assert_equal "400", res.code
|
122
|
+
|
123
|
+
# TODO make this a headers_for_private_download like in the s3 service
|
124
|
+
download_headers = {
|
125
|
+
"content-type" => "binary/octet-stream",
|
126
|
+
"Content-Disposition" => "inline; filename=\"temp.bin\"; filename*=UTF-8''temp.bin",
|
127
|
+
"x-goog-encryption-algorithm" => "AES256",
|
128
|
+
"x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]),
|
129
|
+
"x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range])
|
130
|
+
}
|
131
|
+
download_headers.each_pair { |key, value| req[key] = value }
|
132
|
+
|
133
|
+
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http|
|
134
|
+
http.request(req)
|
135
|
+
}
|
136
|
+
assert_equal "200", res.code
|
137
|
+
assert_equal plaintext_upload_bytes, res.body
|
138
|
+
end
|
139
|
+
|
140
|
+
def test_basic_gcs_readback
|
141
|
+
rng = Random.new(Minitest.seed)
|
142
|
+
|
143
|
+
key = "#{run_id}-encrypted-key-#{rng.hex(4)}"
|
144
|
+
encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
|
145
|
+
plaintext_upload_bytes = rng.bytes(1024)
|
146
|
+
|
147
|
+
assert_nothing_raised do
|
148
|
+
@service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:)
|
149
|
+
end
|
150
|
+
readback = @service.download(key, encryption_key:)
|
151
|
+
assert_equal readback, plaintext_upload_bytes
|
152
|
+
end
|
153
|
+
|
154
|
+
def test_accepts_direct_upload_with_signature_and_headers
|
155
|
+
rng = Random.new(Minitest.seed)
|
156
|
+
|
157
|
+
key = "#{run_id}-encrypted-key-direct-upload-#{rng.hex(4)}"
|
158
|
+
encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it
|
159
|
+
plaintext_upload_bytes = rng.bytes(1024)
|
160
|
+
|
161
|
+
url = @service.url_for_direct_upload(key,
|
162
|
+
encryption_key:,
|
163
|
+
expires_in: 5.minutes.to_i,
|
164
|
+
content_type: "binary/octet-stream",
|
165
|
+
content_length: plaintext_upload_bytes.bytesize,
|
166
|
+
checksum: Digest::MD5.base64digest(plaintext_upload_bytes))
|
167
|
+
|
168
|
+
query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h
|
169
|
+
|
170
|
+
# Downcased header names for this test since that's what we get back from signing process.
|
171
|
+
expected_headers = ["content-md5", "x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"]
|
172
|
+
signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";")
|
173
|
+
assert expected_headers.all? { |header| header.in?(signed_headers) }
|
174
|
+
|
175
|
+
assert_equal "300", query_params_hash["X-Goog-Expires"]
|
176
|
+
|
177
|
+
should_be_headers = {
|
178
|
+
"Content-Type" => "binary/octet-stream",
|
179
|
+
"Content-MD5" => Digest::MD5.base64digest(plaintext_upload_bytes),
|
180
|
+
"x-goog-encryption-algorithm" => "AES256",
|
181
|
+
"x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]),
|
182
|
+
"x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range])
|
183
|
+
}
|
184
|
+
|
185
|
+
headers = @service.headers_for_direct_upload(key,
|
186
|
+
encryption_key:,
|
187
|
+
content_type: "binary/octet-stream",
|
188
|
+
content_length: plaintext_upload_bytes.bytesize,
|
189
|
+
checksum: Digest::MD5.base64digest(plaintext_upload_bytes))
|
190
|
+
|
191
|
+
assert_equal should_be_headers.sort, headers.sort
|
192
|
+
|
193
|
+
res = Net::HTTP.put(URI(url), plaintext_upload_bytes, headers)
|
194
|
+
assert_equal "200", res.code
|
195
|
+
|
196
|
+
assert_equal plaintext_upload_bytes, @service.download(key, encryption_key:)
|
197
|
+
|
198
|
+
@service.delete(key)
|
199
|
+
refute @service.exist?(key)
|
200
|
+
end
|
201
|
+
end
|
@@ -235,7 +235,10 @@ class ActiveStorageEncryption::EncryptedS3ServiceTest < ActiveSupport::TestCase
|
|
235
235
|
# Read the objects from something slow, so that threads may switch between one another
|
236
236
|
class SnoozyStringIO < StringIO
|
237
237
|
def read(n = nil, outbuf = nil)
|
238
|
-
|
238
|
+
sleep_from = 0.1
|
239
|
+
sleep_to = 0.2
|
240
|
+
delay_s = rand(sleep_from..sleep_to)
|
241
|
+
sleep(delay_s)
|
239
242
|
super
|
240
243
|
end
|
241
244
|
end
|