file-digests 0.0.18 → 0.0.19
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/lib/file-digests.rb +202 -211
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7aeebbfa688dc871d736ca71e740b3bdd4804701d29e4b960da16d1f75bdb04f
|
4
|
+
data.tar.gz: 136b2f908e41f56ba2ef243130d4a8a13b156f49b1c179a37225d3d19b0eff1c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9dd356eb305cd87874c3c8bc84905dec4388172e14db4b6a0d78004cf9bf183dc6c508537aa6f2806cc8d0f7eaeec68ccb6d05ea7cc5cf18db95fe86db9f4a15
|
7
|
+
data.tar.gz: 8a2d67681bc07b46fa4e93c280c8355eb639168e7bc1f244d3dae6cfd57e58fde6ded1a4d25aa2f63364bc639a55038936bedf990cc0608b267e2facd60bf556
|
data/lib/file-digests.rb
CHANGED
@@ -5,7 +5,7 @@ require 'fileutils'
|
|
5
5
|
require 'pathname'
|
6
6
|
require 'sqlite3'
|
7
7
|
|
8
|
-
|
8
|
+
class FileDigests
|
9
9
|
|
10
10
|
def self.perform_check
|
11
11
|
options = {
|
@@ -13,271 +13,262 @@ module FileDigests
|
|
13
13
|
quiet: (ENV["QUIET"] == "true"),
|
14
14
|
test_only: (ENV["TEST_ONLY"] == "true")
|
15
15
|
}
|
16
|
-
|
17
|
-
|
16
|
+
file_digests = self.new ARGV[0], ARGV[1], options
|
17
|
+
file_digests.perform_check
|
18
18
|
end
|
19
19
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
@db = SQLite3::Database.new path.to_s
|
25
|
-
@db.results_as_hash = true
|
26
|
-
|
27
|
-
execute 'PRAGMA journal_mode = "WAL"'
|
28
|
-
execute 'PRAGMA synchronous = "NORMAL"'
|
29
|
-
execute 'PRAGMA locking_mode = "EXCLUSIVE"'
|
30
|
-
execute 'PRAGMA cache_size = "5000"'
|
31
|
-
|
32
|
-
unless execute("SELECT name FROM sqlite_master WHERE type='table' AND name = 'digests'").length == 1
|
33
|
-
execute 'PRAGMA encoding = "UTF-8"'
|
34
|
-
execute "CREATE TABLE digests (
|
35
|
-
id INTEGER PRIMARY KEY,
|
36
|
-
filename TEXT,
|
37
|
-
mtime TEXT,
|
38
|
-
digest TEXT,
|
39
|
-
digest_check_time TEXT)"
|
40
|
-
execute "CREATE UNIQUE INDEX digests_filename ON digests(filename)"
|
41
|
-
end
|
20
|
+
def initialize files_path, digest_database_path, options = {}
|
21
|
+
@options = options
|
22
|
+
@files_path = cleanup_path(files_path || ".")
|
23
|
+
@prefix_to_remove = @files_path.to_s + '/'
|
42
24
|
|
43
|
-
|
44
|
-
@new_files = {}
|
25
|
+
raise "Files path must be a readable directory" unless (File.directory?(@files_path) && File.readable?(@files_path))
|
45
26
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
prepare_method :update_mtime, "UPDATE digests SET mtime = ?, digest_check_time = datetime('now') WHERE id = ?"
|
51
|
-
prepare_method :delete_by_filename, "DELETE FROM digests WHERE filename = ?"
|
27
|
+
@digest_database_path = if digest_database_path
|
28
|
+
cleanup_path(digest_database_path)
|
29
|
+
else
|
30
|
+
@files_path + '.file-digests.sqlite'
|
52
31
|
end
|
53
32
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
if found = result.next_hash
|
58
|
-
raise "Multiple records found" if result.next
|
59
|
-
|
60
|
-
@missing_files.delete(file_path)
|
61
|
-
|
62
|
-
if found['digest'] == digest
|
63
|
-
counters[:good] += 1
|
64
|
-
# puts "GOOD: #{file_path}" unless @options[:quiet]
|
65
|
-
unless @options[:test_only]
|
66
|
-
if found['mtime'] == mtime
|
67
|
-
touch_digest_check_time found['id']
|
68
|
-
else
|
69
|
-
update_mtime mtime, found['id']
|
70
|
-
end
|
71
|
-
end
|
72
|
-
else
|
73
|
-
if found['mtime'] == mtime # Digest is different and mtime is the same
|
74
|
-
counters[:likely_damaged] += 1
|
75
|
-
STDERR.puts "LIKELY DAMAGED: #{file_path}"
|
76
|
-
else
|
77
|
-
counters[:updated] += 1
|
78
|
-
puts "UPDATED: #{file_path}" unless @options[:quiet]
|
79
|
-
unless @options[:test_only]
|
80
|
-
update_mtime_and_digest mtime, digest, found['id']
|
81
|
-
end
|
82
|
-
end
|
83
|
-
end
|
84
|
-
else
|
85
|
-
counters[:new] += 1
|
86
|
-
puts "NEW: #{file_path}" unless @options[:quiet]
|
87
|
-
unless @options[:test_only]
|
88
|
-
@new_files[file_path] = digest
|
89
|
-
insert file_path, mtime, digest
|
90
|
-
end
|
91
|
-
end
|
33
|
+
if File.directory?(@digest_database_path)
|
34
|
+
@digest_database_path = @digest_database_path + '.file-digests.sqlite'
|
92
35
|
end
|
93
36
|
|
94
|
-
|
95
|
-
@
|
96
|
-
if @new_files.value?(digest)
|
97
|
-
counters[:renamed] += 1
|
98
|
-
unless @options[:test_only]
|
99
|
-
delete_by_filename filename
|
100
|
-
end
|
101
|
-
true
|
102
|
-
end
|
103
|
-
end
|
104
|
-
counters[:missing] = @missing_files.length
|
37
|
+
if @files_path == @digest_database_path.dirname
|
38
|
+
@skip_file_digests_sqlite = true
|
105
39
|
end
|
106
40
|
|
107
|
-
|
108
|
-
@missing_files.length > 0
|
109
|
-
end
|
41
|
+
ensure_dir_exists @digest_database_path.dirname
|
110
42
|
|
111
|
-
|
112
|
-
|
113
|
-
@
|
114
|
-
puts filename
|
115
|
-
end
|
43
|
+
# Please do not use this flag, support for sha512 is here for backward compatibility, and one day it will be removed.
|
44
|
+
if File.exist?(@digest_database_path.dirname + '.file-digests.sha512')
|
45
|
+
@use_sha512 = true
|
116
46
|
end
|
117
47
|
|
118
|
-
|
119
|
-
@db.transaction do
|
120
|
-
@missing_files.each do |filename, digest|
|
121
|
-
delete_by_filename filename
|
122
|
-
end
|
123
|
-
end
|
124
|
-
end
|
125
|
-
|
126
|
-
private
|
48
|
+
initialize_database @digest_database_path
|
127
49
|
|
128
|
-
|
129
|
-
@db.execute *args, &block
|
130
|
-
end
|
131
|
-
|
132
|
-
def prepare_method name, query
|
133
|
-
variable = "@#{name}"
|
134
|
-
instance_variable_set(variable, @db.prepare(query))
|
135
|
-
define_singleton_method name do |*args, &block|
|
136
|
-
instance_variable_get(variable).execute(*args, &block)
|
137
|
-
end
|
138
|
-
end
|
50
|
+
@counters = {good: 0, updated: 0, new: 0, missing: 0, renamed: 0, likely_damaged: 0, exceptions: 0}
|
139
51
|
end
|
140
52
|
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
53
|
+
def initialize_database path
|
54
|
+
@db = SQLite3::Database.new path.to_s
|
55
|
+
@db.results_as_hash = true
|
56
|
+
|
57
|
+
execute 'PRAGMA journal_mode = "WAL"'
|
58
|
+
execute 'PRAGMA synchronous = "NORMAL"'
|
59
|
+
execute 'PRAGMA locking_mode = "EXCLUSIVE"'
|
60
|
+
execute 'PRAGMA cache_size = "5000"'
|
61
|
+
|
62
|
+
unless execute("SELECT name FROM sqlite_master WHERE type='table' AND name = 'digests'").length == 1
|
63
|
+
execute 'PRAGMA encoding = "UTF-8"'
|
64
|
+
execute "CREATE TABLE digests (
|
65
|
+
id INTEGER PRIMARY KEY,
|
66
|
+
filename TEXT,
|
67
|
+
mtime TEXT,
|
68
|
+
digest TEXT,
|
69
|
+
digest_check_time TEXT)"
|
70
|
+
execute "CREATE UNIQUE INDEX digests_filename ON digests(filename)"
|
71
|
+
end
|
148
72
|
|
149
|
-
|
150
|
-
|
151
|
-
else
|
152
|
-
@files_path + '.file-digests.sqlite'
|
153
|
-
end
|
73
|
+
@missing_files = Hash[@db.prepare("SELECT filename, digest FROM digests").execute!]
|
74
|
+
@new_files = {}
|
154
75
|
|
155
|
-
|
156
|
-
|
157
|
-
|
76
|
+
prepare_method :insert, "INSERT INTO digests (filename, mtime, digest, digest_check_time) VALUES (?, ?, ?, datetime('now'))"
|
77
|
+
prepare_method :find_by_filename, "SELECT id, mtime, digest FROM digests WHERE filename = ?"
|
78
|
+
prepare_method :touch_digest_check_time, "UPDATE digests SET digest_check_time = datetime('now') WHERE id = ?"
|
79
|
+
prepare_method :update_mtime_and_digest, "UPDATE digests SET mtime = ?, digest = ?, digest_check_time = datetime('now') WHERE id = ?"
|
80
|
+
prepare_method :update_mtime, "UPDATE digests SET mtime = ?, digest_check_time = datetime('now') WHERE id = ?"
|
81
|
+
prepare_method :delete_by_filename, "DELETE FROM digests WHERE filename = ?"
|
82
|
+
end
|
158
83
|
|
159
|
-
|
160
|
-
|
84
|
+
def perform_check
|
85
|
+
measure_time do
|
86
|
+
walk_files do |filename|
|
87
|
+
process_file filename
|
161
88
|
end
|
89
|
+
end
|
162
90
|
|
163
|
-
|
91
|
+
track_renames
|
164
92
|
|
165
|
-
|
166
|
-
|
167
|
-
|
93
|
+
if any_missing_files?
|
94
|
+
print_missing_files
|
95
|
+
if !@options[:test_only] && (@options[:auto] || confirm("Remove missing files from the database"))
|
96
|
+
remove_missing_files
|
168
97
|
end
|
98
|
+
end
|
169
99
|
|
170
|
-
|
171
|
-
|
100
|
+
if @counters[:likely_damaged] > 0 || @counters[:exceptions] > 0
|
101
|
+
STDERR.puts "ERRORS WERE OCCURRED"
|
172
102
|
end
|
173
103
|
|
174
|
-
|
175
|
-
|
176
|
-
walk_files do |filename|
|
177
|
-
process_file filename
|
178
|
-
end
|
179
|
-
end
|
104
|
+
puts @counters.inspect
|
105
|
+
end
|
180
106
|
|
181
|
-
|
107
|
+
private
|
182
108
|
|
183
|
-
|
184
|
-
|
185
|
-
if !@options[:test_only] && (@options[:auto] || confirm("Remove missing files from the database"))
|
186
|
-
@digest_database.remove_missing_files
|
187
|
-
end
|
188
|
-
end
|
109
|
+
def process_file filename
|
110
|
+
return if File.symlink? filename
|
189
111
|
|
190
|
-
|
191
|
-
STDERR.puts "ERRORS WERE OCCURRED"
|
192
|
-
end
|
112
|
+
stat = File.stat filename
|
193
113
|
|
194
|
-
|
114
|
+
return if stat.blockdev?
|
115
|
+
return if stat.chardev?
|
116
|
+
return if stat.directory?
|
117
|
+
return if stat.pipe?
|
118
|
+
unless stat.readable?
|
119
|
+
raise "File is not readable"
|
195
120
|
end
|
121
|
+
return if stat.socket?
|
122
|
+
|
123
|
+
if @skip_file_digests_sqlite
|
124
|
+
basename = File.basename(filename)
|
125
|
+
return if basename == '.file-digests.sha512'
|
126
|
+
return if basename == '.file-digests.sqlite'
|
127
|
+
return if basename == '.file-digests.sqlite-wal'
|
128
|
+
return if basename == '.file-digests.sqlite-shm'
|
129
|
+
end
|
130
|
+
|
131
|
+
insert_or_update(
|
132
|
+
filename.delete_prefix(@prefix_to_remove).encode('utf-8', universal_newline: true).unicode_normalize(:nfkc),
|
133
|
+
stat.mtime.utc.strftime('%Y-%m-%d %H:%M:%S'),
|
134
|
+
get_file_digest(filename)
|
135
|
+
)
|
136
|
+
rescue => exception
|
137
|
+
@counters[:exceptions] += 1
|
138
|
+
STDERR.puts "EXCEPTION: #{filename.encode('utf-8', universal_newline: true)}: #{exception.message}"
|
139
|
+
end
|
196
140
|
|
197
|
-
|
141
|
+
def patch_path_string path
|
142
|
+
Gem.win_platform? ? path.gsub(/\\/, '/') : path
|
143
|
+
end
|
198
144
|
|
145
|
+
def cleanup_path path
|
146
|
+
Pathname.new(patch_path_string(path)).cleanpath
|
147
|
+
end
|
199
148
|
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
149
|
+
def ensure_dir_exists path
|
150
|
+
if File.exist?(path)
|
151
|
+
unless File.directory?(path)
|
152
|
+
raise "#{path} is not a directory"
|
204
153
|
end
|
154
|
+
else
|
155
|
+
FileUtils.mkdir_p path
|
205
156
|
end
|
157
|
+
end
|
206
158
|
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
159
|
+
def walk_files
|
160
|
+
Dir.glob(@files_path + '**' + '*', File::FNM_DOTMATCH) do |filename|
|
161
|
+
yield filename
|
162
|
+
end
|
163
|
+
end
|
211
164
|
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
165
|
+
def get_file_digest filename
|
166
|
+
File.open(filename, 'rb') do |io|
|
167
|
+
digest = (@use_sha512 ? Digest::SHA512 : Digest::SHA256).new
|
168
|
+
buffer = ""
|
169
|
+
while io.read(40960, buffer)
|
170
|
+
digest.update(buffer)
|
218
171
|
end
|
219
|
-
return
|
220
|
-
|
221
|
-
if @skip_file_digests_sqlite
|
222
|
-
basename = File.basename(filename)
|
223
|
-
return if basename == '.file-digests.sha512'
|
224
|
-
return if basename == '.file-digests.sqlite'
|
225
|
-
return if basename == '.file-digests.sqlite-wal'
|
226
|
-
return if basename == '.file-digests.sqlite-shm'
|
227
|
-
end
|
228
|
-
|
229
|
-
@digest_database.insert_or_update(
|
230
|
-
filename.delete_prefix(@prefix_to_remove).encode('utf-8', universal_newline: true).unicode_normalize(:nfkc),
|
231
|
-
stat.mtime.utc.strftime('%Y-%m-%d %H:%M:%S'),
|
232
|
-
get_file_digest(filename),
|
233
|
-
@counters
|
234
|
-
)
|
235
|
-
rescue => exception
|
236
|
-
@counters[:exceptions] += 1
|
237
|
-
STDERR.puts "EXCEPTION: #{filename.encode('utf-8', universal_newline: true)}: #{exception.message}"
|
172
|
+
return digest.hexdigest
|
238
173
|
end
|
174
|
+
end
|
239
175
|
|
240
|
-
|
241
|
-
|
176
|
+
def confirm text
|
177
|
+
if STDIN.tty? && STDOUT.tty?
|
178
|
+
puts "#{text} (y/n)?"
|
179
|
+
STDIN.gets.strip.downcase == "y"
|
242
180
|
end
|
181
|
+
end
|
243
182
|
|
244
|
-
|
245
|
-
|
246
|
-
|
183
|
+
def measure_time
|
184
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
185
|
+
yield
|
186
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start).to_i
|
187
|
+
puts "Elapsed time: #{elapsed / 3600}h #{(elapsed % 3600) / 60}m #{elapsed % 60}s" unless @options[:quiet]
|
188
|
+
end
|
189
|
+
|
190
|
+
def insert_or_update file_path, mtime, digest
|
191
|
+
result = find_by_filename file_path
|
247
192
|
|
248
|
-
|
249
|
-
if
|
250
|
-
|
251
|
-
|
193
|
+
if found = result.next_hash
|
194
|
+
raise "Multiple records found" if result.next
|
195
|
+
|
196
|
+
@missing_files.delete(file_path)
|
197
|
+
|
198
|
+
if found['digest'] == digest
|
199
|
+
@counters[:good] += 1
|
200
|
+
# puts "GOOD: #{file_path}" unless @options[:quiet]
|
201
|
+
unless @options[:test_only]
|
202
|
+
if found['mtime'] == mtime
|
203
|
+
touch_digest_check_time found['id']
|
204
|
+
else
|
205
|
+
update_mtime mtime, found['id']
|
206
|
+
end
|
252
207
|
end
|
253
208
|
else
|
254
|
-
|
209
|
+
if found['mtime'] == mtime # Digest is different and mtime is the same
|
210
|
+
@counters[:likely_damaged] += 1
|
211
|
+
STDERR.puts "LIKELY DAMAGED: #{file_path}"
|
212
|
+
else
|
213
|
+
@counters[:updated] += 1
|
214
|
+
puts "UPDATED: #{file_path}" unless @options[:quiet]
|
215
|
+
unless @options[:test_only]
|
216
|
+
update_mtime_and_digest mtime, digest, found['id']
|
217
|
+
end
|
218
|
+
end
|
255
219
|
end
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
220
|
+
else
|
221
|
+
@counters[:new] += 1
|
222
|
+
puts "NEW: #{file_path}" unless @options[:quiet]
|
223
|
+
unless @options[:test_only]
|
224
|
+
@new_files[file_path] = digest
|
225
|
+
insert file_path, mtime, digest
|
261
226
|
end
|
262
227
|
end
|
228
|
+
end
|
263
229
|
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
230
|
+
def track_renames
|
231
|
+
@missing_files.delete_if do |filename, digest|
|
232
|
+
if @new_files.value?(digest)
|
233
|
+
@counters[:renamed] += 1
|
234
|
+
unless @options[:test_only]
|
235
|
+
delete_by_filename filename
|
270
236
|
end
|
271
|
-
|
237
|
+
true
|
272
238
|
end
|
273
239
|
end
|
240
|
+
@counters[:missing] = @missing_files.length
|
241
|
+
end
|
274
242
|
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
243
|
+
def any_missing_files?
|
244
|
+
@missing_files.length > 0
|
245
|
+
end
|
246
|
+
|
247
|
+
def print_missing_files
|
248
|
+
puts "\nMISSING FILES:"
|
249
|
+
@missing_files.sort.to_h.each do |filename, digest|
|
250
|
+
puts filename
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
def remove_missing_files
|
255
|
+
@db.transaction do
|
256
|
+
@missing_files.each do |filename, digest|
|
257
|
+
delete_by_filename filename
|
258
|
+
end
|
280
259
|
end
|
260
|
+
end
|
281
261
|
|
262
|
+
def execute *args, &block
|
263
|
+
@db.execute *args, &block
|
282
264
|
end
|
265
|
+
|
266
|
+
def prepare_method name, query
|
267
|
+
variable = "@#{name}"
|
268
|
+
instance_variable_set(variable, @db.prepare(query))
|
269
|
+
define_singleton_method name do |*args, &block|
|
270
|
+
instance_variable_get(variable).execute(*args, &block)
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
283
274
|
end
|