102 changes: 86 additions & 16 deletions lib/fpm/util.rb
@@ -1,6 +1,7 @@
require "fpm/namespace"
require "childprocess"
require "ffi"
require "fileutils"

# Some utility functions
module FPM::Util
Expand Down Expand Up @@ -190,9 +191,14 @@ def safesystem(*args)
if args.size == 1
args = [ default_shell, "-c", args[0] ]
end
program = args[0]

exit_code = execmd(args)
if args[0].kind_of?(Hash)
env = args.shift()
exit_code = execmd(env, args)
else
exit_code = execmd(args)
end
program = args[0]
success = (exit_code == 0)

if !success
Expand Down Expand Up @@ -226,26 +232,90 @@ def safesystemout(*args)
return stdout_r_str
end # def safesystemout

# Get an array containing the recommended 'ar' command for this platform
# and the recommended options to quickly create/append to an archive
# without timestamps or uids (if possible).
def ar_cmd
return @@ar_cmd if defined? @@ar_cmd

@@ar_cmd_deterministic = FALSE

# FIXME: don't assume current directory writeable
FileUtils.touch(["fpm-dummy.tmp"])
["ar", "gar"].each do |ar|
["-qc", "-qcD"].each do |ar_create_opts|
FileUtils.rm_f(["fpm-dummy.ar.tmp"])
# Return this combination if it creates archives without uids or timestamps.
# Exitstatus will be nonzero if the archive can't be created,
# or its table of contents doesn't match the regular expression.
# Be extra-careful about locale and timezone when matching output.
system("#{ar} #{ar_create_opts} fpm-dummy.ar.tmp fpm-dummy.tmp 2>/dev/null && env TZ=UTC LANG=C LC_TIME=C #{ar} -tv fpm-dummy.ar.tmp | grep '0/0.*1970' > /dev/null 2>&1")
if $?.exitstatus == 0
@@ar_cmd = [ar, ar_create_opts]
@@ar_cmd_deterministic = TRUE
return @@ar_cmd
end
end
end
# If no combination of ar and options omits timestamps, fall back to default.
@@ar_cmd = ["ar", "-qc"]
return @@ar_cmd
ensure
# Clean up
FileUtils.rm_f(["fpm-dummy.ar.tmp", "fpm-dummy.tmp"])
end # def ar_cmd

# Return whether the command returned by ar_cmd can create deterministic archives
def ar_cmd_deterministic?
ar_cmd if not defined? @@ar_cmd_deterministic
return @@ar_cmd_deterministic
end

# Get the recommended 'tar' command for this platform.
def tar_cmd
# Rely on gnu tar for solaris and OSX.
case %x{uname -s}.chomp
when "SunOS"
return "gtar"
when "Darwin"
# Try running gnutar, it was renamed(??) in homebrew to 'gtar' at some point, I guess? I don't know.
["gnutar", "gtar"].each do |tar|
system("#{tar} > /dev/null 2> /dev/null")
return tar unless $?.exitstatus == 127
return @@tar_cmd if defined? @@tar_cmd

# FIXME: don't assume current directory writeable
FileUtils.touch(["fpm-dummy.tmp"])

# Prefer tar that supports more of the features we want, stop if we find tar of our dreams
best="tar"
bestscore=0
@@tar_cmd_deterministic = FALSE
# GNU Tar, if not the default, is usually on the path as gtar, but
# Mac OS X 10.8 and earlier shipped it as /usr/bin/gnutar
["tar", "gtar", "gnutar"].each do |tar|
opts=[]
score=0
["--sort=name", "--mtime=@0"].each do |opt|
system("#{tar} #{opt} -cf fpm-dummy.tar.tmp fpm-dummy.tmp > /dev/null 2>&1")
if $?.exitstatus == 0
opts << opt
score += 1
end
end
if score > bestscore
best=tar
bestscore=score
if score == 2
@@tar_cmd_deterministic = TRUE
break
end
end
when "FreeBSD"
# use gnutar instead
return "gtar"
else
return "tar"
end
@@tar_cmd = best
return @@tar_cmd
ensure
# Clean up
FileUtils.rm_f(["fpm-dummy.tar.tmp", "fpm-dummy.tmp"])
end # def tar_cmd

# Return whether the command returned by tar_cmd can create deterministic archives
def tar_cmd_supports_sort_names_and_set_mtime?
tar_cmd if not defined? @@tar_cmd_deterministic
return @@tar_cmd_deterministic
end

# wrapper around mknod ffi calls
def mknod_w(path, mode, dev)
rc = -1
Expand Down
55 changes: 54 additions & 1 deletion spec/fpm/package/deb_spec.rb
Expand Up @@ -174,7 +174,7 @@
context "when the deb's control section is extracted" do
let(:control_dir) { Stud::Temporary.directory }
before do
system("ar p '#{target}' control.tar.gz | tar -zx -C '#{control_dir}' -f -")
system(ar_cmd[0] + " p '#{target}' control.tar.gz | tar -zx -C '#{control_dir}' -f -")
raise "couldn't extract test deb" unless $CHILD_STATUS.success?
end

Expand Down Expand Up @@ -375,4 +375,57 @@ def dpkg_field(field)
end
end
end

describe "#reproducible" do

let(:package) {
# Turn on reproducible build behavior by setting SOURCE_DATE_EPOCH like user would
val = FPM::Package::Deb.new
val.attributes[:source_date_epoch] = '1' # one second into Jan 1 1970 UTC... '0' not supported by zlib binding :-(
val
}

before :each do
package.name = "name"
File.unlink(target + '.orig') if File.exist?(target + '.orig')
end

after :each do
package.cleanup
File.unlink(target + '.orig') if File.exist?(target + '.orig')
end # after

it "it should output bit-for-bit identical packages" do
lamecmds = []
lamecmds << "ar" if not ar_cmd_deterministic?
lamecmds << "tar" if not tar_cmd_supports_sort_names_and_set_mtime?
if not lamecmds.empty?
skip("fpm searched for variants of #{lamecmds.join(", ")} that support(s) deterministic archives, but found none, so can't test reproducibility.")
return
end

package.output(target)
# FIXME: 2nd and later runs create changelog.Debian.gz?!, so throw away output of 1st run
FileUtils.rm(target)
package.output(target)
FileUtils.mv(target, target + '.orig')
# Output a second time with a different timestamp; tar format time resolution is 1 second
sleep(1)
package.output(target)

# Show detailed differences, if diffoscope is on PATH; else do nothing
tmp = ENV['TMP'] || "/tmp"
log = File.join(tmp, "diffoscope.log.tmp")
system('diffoscope %s %s > %s 2> /dev/null' % [target, target + '.orig', log])
diffoscope_diff_length = File.size(log)
if (diffoscope_diff_length > 0)
puts("\nDiffoscope reports:")
puts(File.read(log))
end
expect(diffoscope_diff_length).to(be == 0)
File.unlink(log)

expect(FileUtils.compare_file(target, target + '.orig')).to be true
end
end # #reproducible
end # describe FPM::Package::Deb
36 changes: 36 additions & 0 deletions spec/fpm/package/gem_spec.rb
Expand Up @@ -99,4 +99,40 @@
insist { File.readlines(file_path).grep("#!/opt/special/bin/ruby\n").any? } == true
end
end

context "when confronted with a multiplicity of changelog formats" do
# FIXME: don't expose RE's, provide a more stable interface

it 'should recognize these formats' do
r1 = Regexp.new(FPM::Package::Gem::P_RE_VERSION_DATE)
r2 = Regexp.new(FPM::Package::Gem::P_RE_DATE_VERSION)
[
[ "cabin", "v0.1.7 (2011-11-07)", "0.1.7", "2011-11-07", "1320624000" ],
[ "chandler", "## [0.7.0][] (2016-12-23)", "0.7.0", "2016-12-23", "1482451200" ],
[ "domain_name", "## [v0.5.20170404](https://github.com/knu/ruby-domain_name/tree/v0.5.20170404) (2017-04-04)", "0.5.20170404", "2017-04-04", "1491264000" ],
[ "parseconfig", "Mon Jan 25, 2016 - v1.0.8", "1.0.8", "Mon Jan 25, 2016", "1453680000" ],
[ "rack_csrf", "# v2.6.0 (2016-12-31)", "2.6.0", "2016-12-31", "1483142400" ],
[ "sinatra", "= 1.4.7 / 2016-01-24", "1.4.7", "2016-01-24", "1453593600" ],
].each do |gem, line, version, date, unixdate|
v = ""
d = ""
[r1, r2].each do |r|
if r.match(line)
d = $~[:date]
v = $~[:version]
break
end
end
if (d == "")
puts("RE failed to match for gem #{gem}, #{line}")
end
e = Date.parse(d)
u = e.strftime("%s")
insist { v } == version
insist { d } == date
insist { u } == unixdate
end
end
end

end # describe FPM::Package::Gem
2 changes: 1 addition & 1 deletion templates/deb/changelog.erb
Expand Up @@ -2,4 +2,4 @@

* Package created with FPM.

-- <%= maintainer %> <%= Time.now.strftime("%a, %d %b %Y %T %z") %>
-- <%= maintainer %> <%= (if attributes[:source_date_epoch].nil? then Time.now() else Time.at(attributes[:source_date_epoch].to_i) end).strftime("%a, %d %b %Y %T %z") %>