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. We require entries to be flat basenames (no slashes, no ‘..`, no NUL bytes, no leading dot, no leading hyphen) so an attacker can never write outside the target directory or hide files from `ls`. The `./` prefix permitted by tar is normalised away before this match runs.

Functionally identical to ‘ReactOnRailsPro::RollingDeploy::SAFE_HASH_PATTERN` — kept as a separate constant because tarball entry names and rolling-deploy hash strings are conceptually distinct: a future protocol change might tighten one without the other.

/\A[A-Za-z0-9_][A-Za-z0-9_.\-]*\z/

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.



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 74

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.



62
63
64
65
66
67
68
69
70
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 62

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.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/react_on_rails_pro/rolling_deploy/tarball.rb', line 116

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