Module: ReactOnRailsPro::RollingDeploy::Tarball

Defined in:
lib/react_on_rails_pro/rolling_deploy/tarball.rb

Overview

Pure-Ruby tar.gz compose/extract used by the built-in HTTP rolling-deploy adapter. Stdlib only (Gem::Package::TarWriter / TarReader + Zlib) so the gem stays free of native or third-party tarball dependencies.

Wire format (one tarball per bundle hash):

./bundle.js
./loadable-stats.json          (optional)
./react-client-manifest.json   (when RSC enabled)
./react-server-client-manifest.json (when RSC enabled)

All entries are flat — the adapter renames bundle.js to <hash>.js during staging. The compose side only writes regular files keyed by their basename; the extract side rejects anything that isn't a basename so a malicious server can't write outside dest_dir.

Constant Summary collapse

DEFAULT_MAX_SIZE =

Default uncompressed size cap. Bundles are mostly text and gzip well, but a malicious or misconfigured server could still send a multi-GB payload; cap the uncompressed total at 200 MB unless the caller overrides. Used by extract as a guard against zip-bomb-style payloads.

200 * 1024 * 1024
EXTRACT_CHUNK_SIZE =

Per-entry read chunk during extract. Small enough that an inner large file doesn't blow heap before the running total trips the size cap.

64 * 1024
ENTRY_NAME_PATTERN =

Path-safety regex for tar entries. Reuse RendererArtifact's companion contract while keeping the transport-specific TarWriter byte limit at this boundary. The shared pattern rejects slash, backslash, colon, NUL/ASCII controls, and the special . / .. directory entries while permitting ordinary filename characters such as spaces, @, %, Unicode, and leading dots or hyphens. The ./ prefix permitted by tar is normalised away before this match runs. RubyGems TarWriter stores a flat name in a 100-byte header field.

ReactOnRailsPro::RendererArtifact::SAFE_COMPANION_NAME_PATTERN
ENTRY_NAME_MAX_BYTES =
100

Class Method Summary collapse

Class Method Details

.compose_to_io(entries, io) ⇒ Object

Compose a gzipped tarball into the given IO. The IO must accept binary writes. See compose_to_tempfile for the entries shape.



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 94

def compose_to_io(entries, io)
  validate_compose_entries!(entries)

  gz = Zlib::GzipWriter.new(io)
  begin
    Gem::Package::TarWriter.new(gz) do |tar|
      entries.each do |name, path|
        add_file_to_tar(tar, name, path)
      end
    end
    gz.finish
  rescue StandardError
    # On the happy path `gz.finish` flushes and closes the writer; on the
    # exceptional path the TarWriter's `ensure` already wrote the tar EOF
    # record into `gz`, so we just need to release the GzipWriter's IO
    # reference before re-raising. Swallow any close error so the
    # original exception still surfaces.
    begin
      gz.close
    rescue StandardError
      nil
    end
    raise
  end
  io
end

.compose_to_tempfile(entries) ⇒ Object

Compose a gzipped tarball from the given entries and yield the resulting Tempfile. The temp file is removed when the block returns.

entries is a Hash mapping the archive entry name (a flat basename, e.g. "bundle.js") to the source file path on disk. Hash form lets callers rename the file in the archive — on the server side, the server bundle lives at <hash>.js on disk but ships as bundle.js in the tarball so the client adapter can stage it without needing to know the source filename.



82
83
84
85
86
87
88
89
90
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 82

def compose_to_tempfile(entries)
  Tempfile.create(["rolling-deploy-tarball-", ".tar.gz"]) do |tempfile|
    tempfile.binmode
    compose_to_io(entries, tempfile)
    tempfile.flush
    tempfile.rewind
    yield tempfile
  end
end

.extract(source, dest_dir, max_size: DEFAULT_MAX_SIZE) ⇒ Object

Extract a gzipped tarball from source (an IO-like or a String) into dest_dir. Enforces:

* Each entry name must be a safe basename (ENTRY_NAME_PATTERN).
* Each entry must be a regular file (no dirs, symlinks, hardlinks).
* Cumulative uncompressed bytes must not exceed `max_size`.

Returns the list of basenames extracted, in the order seen in the archive. Raises ReactOnRailsPro::Error on any safety or size violation.

Cleanup contract: each entry is written to a Tempfile inside dest_dir and atomically renamed into place only after the size cap check passes for that entry. On raise, no partial file is left at the entry's final path, but earlier entries in the archive may have been written successfully. Callers must rm_rf dest_dir when extract raises so partial archives don't leak into the cache.



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 136

def extract(source, dest_dir, max_size: DEFAULT_MAX_SIZE)
  FileUtils.mkdir_p(dest_dir)
  io = source.is_a?(String) ? StringIO.new(source) : source
  extracted = []
  total_size = 0

  Zlib::GzipReader.wrap(io) do |gz|
    Gem::Package::TarReader.new(gz) do |tar|
      tar.each do |entry|
        name = safe_entry_name!(entry)
        raise_unless_regular_file!(entry, name)
        total_size = write_entry!(entry, dest_dir, name, total_size, max_size)
        extracted << name
      end
    end
  end
  extracted
rescue Zlib::GzipFile::Error, Zlib::DataError => e
  raise ReactOnRailsPro::Error,
        "Rolling-deploy tarball is not a valid gzip stream: #{e.class}: #{e.message}"
rescue Gem::Package::Error => e
  # `Gem::Package::TarReader` raises subclasses of Gem::Package::Error
  # (e.g. TarInvalidError) on malformed tar headers inside an
  # otherwise-valid gzip stream. Wrap them in our error type so the HTTP
  # adapter's StandardError rescue produces a "tarball is malformed"
  # warning instead of leaking the underlying RubyGems class.
  raise ReactOnRailsPro::Error,
        "Rolling-deploy tarball has malformed tar entries: #{e.class}: #{e.message}"
end

.safe_entry_name?(value) ⇒ Boolean

Returns:

  • (Boolean)


66
67
68
69
70
71
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 66

def safe_entry_name?(value)
  name = value.to_s.dup.force_encoding(Encoding::UTF_8)
  name.valid_encoding? &&
    name.bytesize <= ENTRY_NAME_MAX_BYTES &&
    ReactOnRailsPro::RendererArtifact.safe_companion_name?(name)
end