summaryrefslogtreecommitdiff
path: root/lib/rubygems/package
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rubygems/package')
-rw-r--r--lib/rubygems/package/digest_io.rb63
-rw-r--r--lib/rubygems/package/file_source.rb32
-rw-r--r--lib/rubygems/package/io_source.rb48
-rw-r--r--lib/rubygems/package/old.rb169
-rw-r--r--lib/rubygems/package/source.rb4
-rw-r--r--lib/rubygems/package/tar_header.rb277
-rw-r--r--lib/rubygems/package/tar_reader.rb103
-rw-r--r--lib/rubygems/package/tar_reader/entry.rb244
-rw-r--r--lib/rubygems/package/tar_writer.rb332
9 files changed, 1272 insertions, 0 deletions
diff --git a/lib/rubygems/package/digest_io.rb b/lib/rubygems/package/digest_io.rb
new file mode 100644
index 0000000000..f04ab97462
--- /dev/null
+++ b/lib/rubygems/package/digest_io.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+##
+# IO wrapper that creates digests of contents written to the IO it wraps.
+
+class Gem::Package::DigestIO
+ ##
+ # Collected digests for wrapped writes.
+ #
+ # {
+ # 'SHA1' => #<OpenSSL::Digest: [...]>,
+ # 'SHA512' => #<OpenSSL::Digest: [...]>,
+ # }
+
+ attr_reader :digests
+
+ ##
+ # Wraps +io+ and updates digest for each of the digest algorithms in
+ # the +digests+ Hash. Returns the digests hash. Example:
+ #
+ # io = StringIO.new
+ # digests = {
+ # 'SHA1' => OpenSSL::Digest.new('SHA1'),
+ # 'SHA512' => OpenSSL::Digest.new('SHA512'),
+ # }
+ #
+ # Gem::Package::DigestIO.wrap io, digests do |digest_io|
+ # digest_io.write "hello"
+ # end
+ #
+ # digests['SHA1'].hexdigest #=> "aaf4c61d[...]"
+ # digests['SHA512'].hexdigest #=> "9b71d224[...]"
+
+ def self.wrap(io, digests)
+ digest_io = new io, digests
+
+ yield digest_io
+
+ digests
+ end
+
+ ##
+ # Creates a new DigestIO instance. Using ::wrap is recommended, see the
+ # ::wrap documentation for documentation of +io+ and +digests+.
+
+ def initialize(io, digests)
+ @io = io
+ @digests = digests
+ end
+
+ ##
+ # Writes +data+ to the underlying IO and updates the digests
+
+ def write(data)
+ result = @io.write data
+
+ @digests.each do |_, digest|
+ digest << data
+ end
+
+ result
+ end
+end
diff --git a/lib/rubygems/package/file_source.rb b/lib/rubygems/package/file_source.rb
new file mode 100644
index 0000000000..d9717e0f2a
--- /dev/null
+++ b/lib/rubygems/package/file_source.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+##
+# The primary source of gems is a file on disk, including all usages
+# internal to rubygems.
+#
+# This is a private class, do not depend on it directly. Instead, pass a path
+# object to `Gem::Package.new`.
+
+class Gem::Package::FileSource < Gem::Package::Source # :nodoc: all
+ attr_reader :path
+
+ def initialize(path)
+ @path = path
+ end
+
+ def start
+ @start ||= File.read path, 20
+ end
+
+ def present?
+ File.exist? path
+ end
+
+ def with_write_io(&block)
+ File.open path, "wb", &block
+ end
+
+ def with_read_io(&block)
+ File.open path, "rb", &block
+ end
+end
diff --git a/lib/rubygems/package/io_source.rb b/lib/rubygems/package/io_source.rb
new file mode 100644
index 0000000000..227835dfce
--- /dev/null
+++ b/lib/rubygems/package/io_source.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+##
+# Supports reading and writing gems from/to a generic IO object. This is
+# useful for other applications built on top of rubygems, such as
+# rubygems.org.
+#
+# This is a private class, do not depend on it directly. Instead, pass an IO
+# object to `Gem::Package.new`.
+
+class Gem::Package::IOSource < Gem::Package::Source # :nodoc: all
+ attr_reader :io
+
+ def initialize(io)
+ @io = io
+ end
+
+ def start
+ @start ||= begin
+ if io.pos > 0
+ raise Gem::Package::Error, "Cannot read start unless IO is at start"
+ end
+
+ value = io.read 20
+ io.rewind
+ value
+ end
+ end
+
+ def present?
+ true
+ end
+
+ def with_read_io
+ yield io
+ ensure
+ io.rewind
+ end
+
+ def with_write_io
+ yield io
+ ensure
+ io.rewind
+ end
+
+ def path
+ end
+end
diff --git a/lib/rubygems/package/old.rb b/lib/rubygems/package/old.rb
new file mode 100644
index 0000000000..1a13ac3e29
--- /dev/null
+++ b/lib/rubygems/package/old.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+#--
+# Copyright 2006 by Chad Fowler, Rich Kilmer, Jim Weirich and others.
+# All rights reserved.
+# See LICENSE.txt for permissions.
+#++
+
+##
+# The format class knows the guts of the ancient .gem file format and provides
+# the capability to read such ancient gems.
+#
+# Please pretend this doesn't exist.
+
+class Gem::Package::Old < Gem::Package
+ undef_method :spec=
+
+ ##
+ # Creates a new old-format package reader for +gem+. Old-format packages
+ # cannot be written.
+
+ def initialize(gem, security_policy)
+ require "fileutils"
+ require "zlib"
+ Gem.load_yaml
+
+ @contents = nil
+ @gem = gem
+ @security_policy = security_policy
+ @spec = nil
+ end
+
+ ##
+ # A list of file names contained in this gem
+
+ def contents
+ verify
+
+ return @contents if @contents
+
+ @gem.with_read_io do |io|
+ read_until_dashes io # spec
+ header = file_list io
+
+ @contents = header.map {|file| file["path"] }
+ end
+ end
+
+ ##
+ # Extracts the files in this package into +destination_dir+
+
+ def extract_files(destination_dir)
+ verify
+
+ errstr = "Error reading files from gem"
+
+ @gem.with_read_io do |io|
+ read_until_dashes io # spec
+ header = file_list io
+ raise Gem::Exception, errstr unless header
+
+ header.each do |entry|
+ full_name = entry["path"]
+
+ destination = install_location full_name, destination_dir
+
+ file_data = String.new
+
+ read_until_dashes io do |line|
+ file_data << line
+ end
+
+ file_data = file_data.strip.unpack1("m")
+ file_data = Zlib::Inflate.inflate file_data
+
+ raise Gem::Package::FormatError, "#{full_name} in #{@gem} is corrupt" if
+ file_data.length != entry["size"].to_i
+
+ FileUtils.rm_rf destination
+
+ FileUtils.mkdir_p File.dirname(destination), mode: dir_mode && 0o755
+
+ File.open destination, "wb", file_mode(entry["mode"]) do |out|
+ out.write file_data
+ end
+
+ verbose destination
+ end
+ end
+ rescue Zlib::DataError
+ raise Gem::Exception, errstr
+ end
+
+ ##
+ # Reads the file list section from the old-format gem +io+
+
+ def file_list(io) # :nodoc:
+ header = String.new
+
+ read_until_dashes io do |line|
+ header << line
+ end
+
+ Gem::SafeYAML.safe_load header
+ end
+
+ ##
+ # Reads lines until a "---" separator is found
+
+ def read_until_dashes(io) # :nodoc:
+ while (line = io.gets) && line.chomp.strip != "---" do
+ yield line if block_given?
+ end
+ end
+
+ ##
+ # Skips the Ruby self-install header in +io+.
+
+ def skip_ruby(io) # :nodoc:
+ loop do
+ line = io.gets
+
+ return if line.chomp == "__END__"
+ break unless line
+ end
+
+ raise Gem::Exception, "Failed to find end of Ruby script while reading gem"
+ end
+
+ ##
+ # The specification for this gem
+
+ def spec
+ verify
+
+ return @spec if @spec
+
+ yaml = String.new
+
+ @gem.with_read_io do |io|
+ skip_ruby io
+ read_until_dashes io do |line|
+ yaml << line
+ end
+ end
+
+ begin
+ @spec = Gem::Specification.from_yaml yaml
+ rescue Psych::SyntaxError
+ raise Gem::Exception, "Failed to parse gem specification out of gem file"
+ end
+ rescue ArgumentError
+ raise Gem::Exception, "Failed to parse gem specification out of gem file"
+ end
+
+ ##
+ # Raises an exception if a security policy that verifies data is active.
+ # Old format gems cannot be verified as signed.
+
+ def verify
+ return true unless @security_policy
+
+ raise Gem::Security::Exception,
+ "old format gems do not contain signatures and cannot be verified" if
+ @security_policy.verify_data
+
+ true
+ end
+end
diff --git a/lib/rubygems/package/source.rb b/lib/rubygems/package/source.rb
new file mode 100644
index 0000000000..8c44f8c305
--- /dev/null
+++ b/lib/rubygems/package/source.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class Gem::Package::Source # :nodoc:
+end
diff --git a/lib/rubygems/package/tar_header.rb b/lib/rubygems/package/tar_header.rb
new file mode 100644
index 0000000000..dd20d65080
--- /dev/null
+++ b/lib/rubygems/package/tar_header.rb
@@ -0,0 +1,277 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/AsciiComments
+
+# Copyright (C) 2004 Mauricio Julio Fernández Pradier
+# See LICENSE.txt for additional licensing information.
+
+# rubocop:enable Style/AsciiComments
+
+##
+#--
+# struct tarfile_entry_posix {
+# char name[100]; # ASCII + (Z unless filled)
+# char mode[8]; # 0 padded, octal, null
+# char uid[8]; # ditto
+# char gid[8]; # ditto
+# char size[12]; # 0 padded, octal, null
+# char mtime[12]; # 0 padded, octal, null
+# char checksum[8]; # 0 padded, octal, null, space
+# char typeflag[1]; # file: "0" dir: "5"
+# char linkname[100]; # ASCII + (Z unless filled)
+# char magic[6]; # "ustar\0"
+# char version[2]; # "00"
+# char uname[32]; # ASCIIZ
+# char gname[32]; # ASCIIZ
+# char devmajor[8]; # 0 padded, octal, null
+# char devminor[8]; # o padded, octal, null
+# char prefix[155]; # ASCII + (Z unless filled)
+# };
+#++
+# A header for a tar file
+
+class Gem::Package::TarHeader
+ ##
+ # Fields in the tar header
+
+ FIELDS = [
+ :checksum,
+ :devmajor,
+ :devminor,
+ :gid,
+ :gname,
+ :linkname,
+ :magic,
+ :mode,
+ :mtime,
+ :name,
+ :prefix,
+ :size,
+ :typeflag,
+ :uid,
+ :uname,
+ :version,
+ ].freeze
+
+ ##
+ # Pack format for a tar header
+
+ PACK_FORMAT = ("a100" + # name
+ "a8" + # mode
+ "a8" + # uid
+ "a8" + # gid
+ "a12" + # size
+ "a12" + # mtime
+ "a7a" + # chksum
+ "a" + # typeflag
+ "a100" + # linkname
+ "a6" + # magic
+ "a2" + # version
+ "a32" + # uname
+ "a32" + # gname
+ "a8" + # devmajor
+ "a8" + # devminor
+ "a155").freeze # prefix
+
+ ##
+ # Unpack format for a tar header
+
+ UNPACK_FORMAT = ("A100" + # name
+ "A8" + # mode
+ "A8" + # uid
+ "A8" + # gid
+ "A12" + # size
+ "A12" + # mtime
+ "A8" + # checksum
+ "A" + # typeflag
+ "A100" + # linkname
+ "A6" + # magic
+ "A2" + # version
+ "A32" + # uname
+ "A32" + # gname
+ "A8" + # devmajor
+ "A8" + # devminor
+ "A155").freeze # prefix
+
+ attr_reader(*FIELDS)
+
+ EMPTY_HEADER = ("\0" * 512).b.freeze # :nodoc:
+
+ ##
+ # Creates a tar header from IO +stream+
+
+ def self.from(stream)
+ header = stream.read 512
+ return EMPTY if header == EMPTY_HEADER
+
+ fields = header.unpack UNPACK_FORMAT
+
+ new name: fields.shift,
+ mode: strict_oct(fields.shift),
+ uid: oct_or_256based(fields.shift),
+ gid: oct_or_256based(fields.shift),
+ size: strict_oct(fields.shift),
+ mtime: strict_oct(fields.shift),
+ checksum: strict_oct(fields.shift),
+ typeflag: fields.shift,
+ linkname: fields.shift,
+ magic: fields.shift,
+ version: strict_oct(fields.shift),
+ uname: fields.shift,
+ gname: fields.shift,
+ devmajor: strict_oct(fields.shift),
+ devminor: strict_oct(fields.shift),
+ prefix: fields.shift,
+
+ empty: false
+ end
+
+ def self.strict_oct(str)
+ str.strip!
+ return str.oct if /\A[0-7]*\z/.match?(str)
+
+ raise ArgumentError, "#{str.inspect} is not an octal string"
+ end
+
+ def self.oct_or_256based(str)
+ # \x80 flags a positive 256-based number
+ # \ff flags a negative 256-based number
+ # In case we have a match, parse it as a signed binary value
+ # in big-endian order, except that the high-order bit is ignored.
+
+ return str.unpack1("@4N") if /\A[\x80\xff]/n.match?(str)
+ strict_oct(str)
+ end
+
+ ##
+ # Creates a new TarHeader using +vals+
+
+ def initialize(vals)
+ unless vals[:name] && vals[:size] && vals[:prefix] && vals[:mode]
+ raise ArgumentError, ":name, :size, :prefix and :mode required"
+ end
+
+ @checksum = vals[:checksum] || ""
+ @devmajor = vals[:devmajor] || 0
+ @devminor = vals[:devminor] || 0
+ @gid = vals[:gid] || 0
+ @gname = vals[:gname] || "wheel"
+ @linkname = vals[:linkname]
+ @magic = vals[:magic] || "ustar"
+ @mode = vals[:mode]
+ @mtime = vals[:mtime] || 0
+ @name = vals[:name]
+ @prefix = vals[:prefix]
+ @size = vals[:size]
+ @typeflag = vals[:typeflag]
+ @typeflag = "0" if @typeflag.nil? || @typeflag.empty?
+ @uid = vals[:uid] || 0
+ @uname = vals[:uname] || "wheel"
+ @version = vals[:version] || "00"
+
+ @empty = vals[:empty]
+ end
+
+ EMPTY = new({ # :nodoc:
+ checksum: 0,
+ gname: "",
+ linkname: "",
+ magic: "",
+ mode: 0,
+ name: "",
+ prefix: "",
+ size: 0,
+ uname: "",
+ version: 0,
+
+ empty: true,
+ }).freeze
+ private_constant :EMPTY
+
+ ##
+ # Is the tar entry empty?
+
+ def empty?
+ @empty
+ end
+
+ def ==(other) # :nodoc:
+ self.class === other &&
+ @checksum == other.checksum &&
+ @devmajor == other.devmajor &&
+ @devminor == other.devminor &&
+ @gid == other.gid &&
+ @gname == other.gname &&
+ @linkname == other.linkname &&
+ @magic == other.magic &&
+ @mode == other.mode &&
+ @mtime == other.mtime &&
+ @name == other.name &&
+ @prefix == other.prefix &&
+ @size == other.size &&
+ @typeflag == other.typeflag &&
+ @uid == other.uid &&
+ @uname == other.uname &&
+ @version == other.version
+ end
+
+ def to_s # :nodoc:
+ update_checksum
+ header
+ end
+
+ ##
+ # Updates the TarHeader's checksum
+
+ def update_checksum
+ header = header " " * 8
+ @checksum = oct calculate_checksum(header), 6
+ end
+
+ ##
+ # Header's full name, including prefix
+
+ def full_name
+ if prefix != ""
+ File.join prefix, name
+ else
+ name
+ end
+ end
+
+ private
+
+ def calculate_checksum(header)
+ header.sum(0)
+ end
+
+ def header(checksum = @checksum)
+ header = [
+ name,
+ oct(mode, 7),
+ oct(uid, 7),
+ oct(gid, 7),
+ oct(size, 11),
+ oct(mtime, 11),
+ checksum,
+ " ",
+ typeflag,
+ linkname,
+ magic,
+ oct(version, 2),
+ uname,
+ gname,
+ oct(devmajor, 7),
+ oct(devminor, 7),
+ prefix,
+ ]
+
+ header = header.pack PACK_FORMAT
+
+ header.ljust 512, "\0"
+ end
+
+ def oct(num, len)
+ format("%0#{len}o", num)
+ end
+end
diff --git a/lib/rubygems/package/tar_reader.rb b/lib/rubygems/package/tar_reader.rb
new file mode 100644
index 0000000000..b66a8a62bc
--- /dev/null
+++ b/lib/rubygems/package/tar_reader.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/AsciiComments
+
+# Copyright (C) 2004 Mauricio Julio Fernández Pradier
+# See LICENSE.txt for additional licensing information.
+
+# rubocop:enable Style/AsciiComments
+
+##
+# TarReader reads tar files and allows iteration over their items
+
+class Gem::Package::TarReader
+ include Enumerable
+
+ ##
+ # Creates a new TarReader on +io+ and yields it to the block, if given.
+
+ def self.new(io)
+ reader = super
+
+ return reader unless block_given?
+
+ begin
+ yield reader
+ ensure
+ reader.close
+ end
+
+ nil
+ end
+
+ attr_reader :io # :nodoc:
+
+ ##
+ # Creates a new tar file reader on +io+ which needs to respond to #pos,
+ # #eof?, #read, #getc and #pos=
+
+ def initialize(io)
+ @io = io
+ @init_pos = io.pos
+ end
+
+ ##
+ # Close the tar file
+
+ def close
+ end
+
+ ##
+ # Iterates over files in the tarball yielding each entry
+
+ def each
+ return enum_for __method__ unless block_given?
+
+ until @io.eof? do
+ begin
+ header = Gem::Package::TarHeader.from @io
+ rescue ArgumentError => e
+ # Specialize only exceptions from Gem::Package::TarHeader.strict_oct
+ raise e unless e.message.match?(/ is not an octal string$/)
+ raise Gem::Package::TarInvalidError, e.message
+ end
+
+ return if header.empty?
+ entry = Gem::Package::TarReader::Entry.new header, @io
+ yield entry
+ entry.close
+ end
+ end
+
+ alias_method :each_entry, :each
+
+ ##
+ # NOTE: Do not call #rewind during #each
+
+ def rewind
+ if @init_pos == 0
+ @io.rewind
+ else
+ @io.pos = @init_pos
+ end
+ end
+
+ ##
+ # Seeks through the tar file until it finds the +entry+ with +name+ and
+ # yields it. Rewinds the tar file to the beginning when the block
+ # terminates.
+
+ def seek(name) # :yields: entry
+ found = find do |entry|
+ entry.full_name == name
+ end
+
+ return unless found
+
+ yield found
+ ensure
+ rewind
+ end
+end
+
+require_relative "tar_reader/entry"
diff --git a/lib/rubygems/package/tar_reader/entry.rb b/lib/rubygems/package/tar_reader/entry.rb
new file mode 100644
index 0000000000..f837e86fd6
--- /dev/null
+++ b/lib/rubygems/package/tar_reader/entry.rb
@@ -0,0 +1,244 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/AsciiComments
+
+# Copyright (C) 2004 Mauricio Julio Fernández Pradier
+# See LICENSE.txt for additional licensing information.
+
+# rubocop:enable Style/AsciiComments
+
+##
+# Class for reading entries out of a tar file
+
+class Gem::Package::TarReader::Entry
+ ##
+ # Creates a new tar entry for +header+ that will be read from +io+
+ # If a block is given, the entry is yielded and then closed.
+
+ def self.open(header, io, &block)
+ entry = new header, io
+ return entry unless block_given?
+ begin
+ yield entry
+ ensure
+ entry.close
+ end
+ end
+
+ ##
+ # Header for this tar entry
+
+ attr_reader :header
+
+ ##
+ # Creates a new tar entry for +header+ that will be read from +io+
+
+ def initialize(header, io)
+ @closed = false
+ @header = header
+ @io = io
+ @orig_pos = @io.pos
+ @end_pos = @orig_pos + @header.size
+ @read = 0
+ end
+
+ def check_closed # :nodoc:
+ raise IOError, "closed #{self.class}" if closed?
+ end
+
+ ##
+ # Number of bytes read out of the tar entry
+
+ def bytes_read
+ @read
+ end
+
+ ##
+ # Closes the tar entry
+
+ def close
+ return if closed?
+ # Seek to the end of the entry if it wasn't fully read
+ seek(0, IO::SEEK_END)
+ # discard trailing zeros
+ skip = (512 - (@header.size % 512)) % 512
+ @io.read(skip)
+ @closed = true
+ nil
+ end
+
+ ##
+ # Is the tar entry closed?
+
+ def closed?
+ @closed
+ end
+
+ ##
+ # Are we at the end of the tar entry?
+
+ def eof?
+ check_closed
+
+ @read >= @header.size
+ end
+
+ ##
+ # Full name of the tar entry
+
+ def full_name
+ @header.full_name.force_encoding(Encoding::UTF_8)
+ rescue ArgumentError => e
+ raise unless e.message == "string contains null byte"
+ raise Gem::Package::TarInvalidError,
+ "tar is corrupt, name contains null byte"
+ end
+
+ ##
+ # Read one byte from the tar entry
+
+ def getc
+ return nil if eof?
+
+ ret = @io.getc
+ @read += 1 if ret
+
+ ret
+ end
+
+ ##
+ # Is this tar entry a directory?
+
+ def directory?
+ @header.typeflag == "5"
+ end
+
+ ##
+ # Is this tar entry a file?
+
+ def file?
+ @header.typeflag == "0"
+ end
+
+ ##
+ # Is this tar entry a symlink?
+
+ def symlink?
+ @header.typeflag == "2"
+ end
+
+ ##
+ # The position in the tar entry
+
+ def pos
+ check_closed
+
+ bytes_read
+ end
+
+ ##
+ # Seek to the position in the tar entry
+
+ def pos=(new_pos)
+ seek(new_pos, IO::SEEK_SET)
+ end
+
+ def size
+ @header.size
+ end
+
+ alias_method :length, :size
+
+ ##
+ # Reads +maxlen+ bytes from the tar file entry, or the rest of the entry if nil
+
+ def read(maxlen = nil)
+ if eof?
+ return maxlen.to_i.zero? ? "" : nil
+ end
+
+ max_read = [maxlen, @header.size - @read].compact.min
+
+ ret = @io.read max_read
+ if ret.nil?
+ return maxlen ? nil : "" # IO.read returns nil on EOF with len argument
+ end
+ @read += ret.size
+
+ ret
+ end
+
+ def readpartial(maxlen, outbuf = "".b)
+ if eof? && maxlen > 0
+ raise EOFError, "end of file reached"
+ end
+
+ max_read = [maxlen, @header.size - @read].min
+
+ @io.readpartial(max_read, outbuf)
+ @read += outbuf.size
+
+ outbuf
+ end
+
+ ##
+ # Seeks to +offset+ bytes into the tar file entry
+ # +whence+ can be IO::SEEK_SET, IO::SEEK_CUR, or IO::SEEK_END
+
+ def seek(offset, whence = IO::SEEK_SET)
+ check_closed
+
+ new_pos =
+ case whence
+ when IO::SEEK_SET then @orig_pos + offset
+ when IO::SEEK_CUR then @io.pos + offset
+ when IO::SEEK_END then @end_pos + offset
+ else
+ raise ArgumentError, "invalid whence"
+ end
+
+ if new_pos < @orig_pos
+ new_pos = @orig_pos
+ elsif new_pos > @end_pos
+ new_pos = @end_pos
+ end
+
+ pending = new_pos - @io.pos
+
+ return 0 if pending == 0
+
+ if @io.respond_to?(:seek)
+ begin
+ # avoid reading if the @io supports seeking
+ @io.seek new_pos, IO::SEEK_SET
+ pending = 0
+ rescue Errno::EINVAL
+ end
+ end
+
+ # if seeking isn't supported or failed
+ # negative seek requires that we rewind and read
+ if pending < 0
+ @io.rewind
+ pending = new_pos
+ end
+
+ while pending > 0 do
+ size_read = @io.read([pending, 4096].min)&.size
+ raise(EOFError, "end of file reached") if size_read.nil?
+ pending -= size_read
+ end
+
+ @read = @io.pos - @orig_pos
+
+ 0
+ end
+
+ ##
+ # Rewinds to the beginning of the tar file entry
+
+ def rewind
+ check_closed
+ seek(0, IO::SEEK_SET)
+ end
+end
diff --git a/lib/rubygems/package/tar_writer.rb b/lib/rubygems/package/tar_writer.rb
new file mode 100644
index 0000000000..39fed9e2af
--- /dev/null
+++ b/lib/rubygems/package/tar_writer.rb
@@ -0,0 +1,332 @@
+# frozen_string_literal: true
+
+# rubocop:disable Style/AsciiComments
+
+# Copyright (C) 2004 Mauricio Julio Fernández Pradier
+# See LICENSE.txt for additional licensing information.
+
+# rubocop:enable Style/AsciiComments
+
+##
+# Allows writing of tar files
+
+class Gem::Package::TarWriter
+ class FileOverflow < StandardError; end
+
+ ##
+ # IO wrapper that allows writing a limited amount of data
+
+ class BoundedStream
+ ##
+ # Maximum number of bytes that can be written
+
+ attr_reader :limit
+
+ ##
+ # Number of bytes written
+
+ attr_reader :written
+
+ ##
+ # Wraps +io+ and allows up to +limit+ bytes to be written
+
+ def initialize(io, limit)
+ @io = io
+ @limit = limit
+ @written = 0
+ end
+
+ ##
+ # Writes +data+ onto the IO, raising a FileOverflow exception if the
+ # number of bytes will be more than #limit
+
+ def write(data)
+ if data.bytesize + @written > @limit
+ raise FileOverflow, "You tried to feed more data than fits in the file."
+ end
+ @io.write data
+ @written += data.bytesize
+ data.bytesize
+ end
+ end
+
+ ##
+ # IO wrapper that provides only #write
+
+ class RestrictedStream
+ ##
+ # Creates a new RestrictedStream wrapping +io+
+
+ def initialize(io)
+ @io = io
+ end
+
+ ##
+ # Writes +data+ onto the IO
+
+ def write(data)
+ @io.write data
+ end
+ end
+
+ ##
+ # Creates a new TarWriter, yielding it if a block is given
+
+ def self.new(io)
+ writer = super
+
+ return writer unless block_given?
+
+ begin
+ yield writer
+ ensure
+ writer.close
+ end
+
+ nil
+ end
+
+ ##
+ # Creates a new TarWriter that will write to +io+
+
+ def initialize(io)
+ @io = io
+ @closed = false
+ end
+
+ ##
+ # Adds file +name+ with permissions +mode+ and mtime +mtime+ (sets
+ # Gem.source_date_epoch if not specified), and yields an IO for
+ # writing the file to
+
+ def add_file(name, mode, mtime = nil) # :yields: io
+ check_closed
+
+ name, prefix = split_name name
+
+ init_pos = @io.pos
+ @io.write Gem::Package::TarHeader::EMPTY_HEADER # placeholder for the header
+
+ yield RestrictedStream.new(@io) if block_given?
+
+ size = @io.pos - init_pos - 512
+
+ remainder = (512 - (size % 512)) % 512
+ @io.write "\0" * remainder
+
+ final_pos = @io.pos
+ @io.pos = init_pos
+
+ header = Gem::Package::TarHeader.new name: name, mode: mode,
+ size: size, prefix: prefix,
+ mtime: mtime || Gem.source_date_epoch
+
+ @io.write header
+ @io.pos = final_pos
+
+ self
+ end
+
+ ##
+ # Adds +name+ with permissions +mode+ to the tar, yielding +io+ for writing
+ # the file. The +digest_algorithm+ is written to a read-only +name+.sum
+ # file following the given file contents containing the digest name and
+ # hexdigest separated by a tab.
+ #
+ # The created digest object is returned.
+
+ def add_file_digest(name, mode, digest_algorithms) # :yields: io
+ digests = digest_algorithms.map do |digest_algorithm|
+ digest = digest_algorithm.new
+ digest_name =
+ if digest.respond_to? :name
+ digest.name
+ else
+ digest_algorithm.class.name[/::([^:]+)\z/, 1]
+ end
+
+ [digest_name, digest]
+ end
+
+ digests = Hash[*digests.flatten]
+
+ add_file name, mode do |io|
+ Gem::Package::DigestIO.wrap io, digests do |digest_io|
+ yield digest_io
+ end
+ end
+
+ digests
+ end
+
+ ##
+ # Adds +name+ with permissions +mode+ to the tar, yielding +io+ for writing
+ # the file. The +signer+ is used to add a digest file using its
+ # digest_algorithm per add_file_digest and a cryptographic signature in
+ # +name+.sig. If the signer has no key only the checksum file is added.
+ #
+ # Returns the digest.
+
+ def add_file_signed(name, mode, signer)
+ digest_algorithms = [
+ signer.digest_algorithm,
+ Gem::Security.create_digest("SHA512"),
+ ].compact.uniq
+
+ digests = add_file_digest name, mode, digest_algorithms do |io|
+ yield io
+ end
+
+ signature_digest = digests.values.compact.find do |digest|
+ digest_name =
+ if digest.respond_to? :name
+ digest.name
+ else
+ digest.class.name[/::([^:]+)\z/, 1]
+ end
+
+ digest_name == signer.digest_name
+ end
+
+ raise "no #{signer.digest_name} in #{digests.values.compact}" unless signature_digest
+
+ if signer.key
+ signature = signer.sign signature_digest.digest
+
+ add_file_simple "#{name}.sig", 0o444, signature.length do |io|
+ io.write signature
+ end
+ end
+
+ digests
+ end
+
+ ##
+ # Add file +name+ with permissions +mode+ +size+ bytes long. Yields an IO
+ # to write the file to.
+
+ def add_file_simple(name, mode, size) # :yields: io
+ check_closed
+
+ name, prefix = split_name name
+
+ header = Gem::Package::TarHeader.new(name: name, mode: mode,
+ size: size, prefix: prefix,
+ mtime: Gem.source_date_epoch).to_s
+
+ @io.write header
+ os = BoundedStream.new @io, size
+
+ yield os if block_given?
+
+ min_padding = size - os.written
+ @io.write("\0" * min_padding)
+
+ remainder = (512 - (size % 512)) % 512
+ @io.write("\0" * remainder)
+
+ self
+ end
+
+ ##
+ # Adds symlink +name+ with permissions +mode+, linking to +target+.
+
+ def add_symlink(name, target, mode)
+ check_closed
+
+ name, prefix = split_name name
+
+ header = Gem::Package::TarHeader.new(name: name, mode: mode,
+ size: 0, typeflag: "2",
+ linkname: target,
+ prefix: prefix,
+ mtime: Gem.source_date_epoch).to_s
+
+ @io.write header
+
+ self
+ end
+
+ ##
+ # Raises IOError if the TarWriter is closed
+
+ def check_closed
+ raise IOError, "closed #{self.class}" if closed?
+ end
+
+ ##
+ # Closes the TarWriter
+
+ def close
+ check_closed
+
+ @io.write "\0" * 1024
+ flush
+
+ @closed = true
+ end
+
+ ##
+ # Is the TarWriter closed?
+
+ def closed?
+ @closed
+ end
+
+ ##
+ # Flushes the TarWriter's IO
+
+ def flush
+ check_closed
+
+ @io.flush if @io.respond_to? :flush
+ end
+
+ ##
+ # Creates a new directory in the tar file +name+ with +mode+
+
+ def mkdir(name, mode)
+ check_closed
+
+ name, prefix = split_name(name)
+
+ header = Gem::Package::TarHeader.new name: name, mode: mode,
+ typeflag: "5", size: 0,
+ prefix: prefix,
+ mtime: Gem.source_date_epoch
+
+ @io.write header
+
+ self
+ end
+
+ ##
+ # Splits +name+ into a name and prefix that can fit in the TarHeader
+
+ def split_name(name) # :nodoc:
+ if name.bytesize > 256
+ raise Gem::Package::TooLongFileName.new("File \"#{name}\" has a too long path (should be 256 or less)")
+ end
+
+ prefix = ""
+ if name.bytesize > 100
+ parts = name.split("/", -1) # parts are never empty here
+ name = parts.pop # initially empty for names with a trailing slash ("foo/.../bar/")
+ prefix = parts.join("/") # if empty, then it's impossible to split (parts is empty too)
+ while !parts.empty? && (prefix.bytesize > 155 || name.empty?)
+ name = parts.pop + "/" + name
+ prefix = parts.join("/")
+ end
+
+ if name.bytesize > 100 || prefix.empty?
+ raise Gem::Package::TooLongFileName.new("File \"#{prefix}/#{name}\" has a too long name (should be 100 or less)")
+ end
+
+ if prefix.bytesize > 155
+ raise Gem::Package::TooLongFileName.new("File \"#{prefix}/#{name}\" has a too long base path (should be 155 or less)")
+ end
+ end
+
+ [name, prefix]
+ end
+end