Class: VagrantDockerHostsManager::Util::HostsFile

Inherits:
Object
  • Object
show all
Defined in:
lib/vagrant-docker-hosts-manager/util/hosts_file.rb

Constant Summary collapse

POSIX_PATH =
"/etc/hosts"
WIN_SYS32_PATH =
"C:/Windows/System32/drivers/etc/hosts"
WIN_SYSNATIVE_PATH =
"C:/Windows/Sysnative/drivers/etc/hosts"
WIN_SYSWOW64_PATH =
"C:/Windows/SysWOW64/drivers/etc/hosts"
BLOCK_START_RE =
/^\s*#\s*>>>\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*>>>\s*$/i
BLOCK_STOP_RE =
/^\s*#\s*<<<\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*<<<\s*$/i
ENTRY_RE =
/\A\s*(\d{1,3}(?:\.\d{1,3}){3})\s+([^\s#]+)/

Instance Method Summary collapse

Constructor Details

#initialize(env, owner_id:) ⇒ HostsFile

Returns a new instance of HostsFile.



24
25
26
27
28
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 24

def initialize(env, owner_id:)
  @env      = env || {}
  @owner_id = owner_id.to_s
  @ui       = env[:ui]
end

Instance Method Details

#apply(entries) ⇒ Integer

Applies managed host entries to the hosts file.

Rewrites only this plugin's managed block and preserves unmanaged lines. Entries are normalized and sorted so repeated runs converge to the same file content.

Parameters:

  • entries (Hash{String=>String})

    Mapping of FQDN to IP address.

Returns:

  • (Integer)

    Number of managed entries present after applying changes.

Raises:

  • (RuntimeError)

    When elevated writes fail.



125
126
127
128
129
130
131
132
133
134
135
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
165
166
167
168
169
170
171
172
173
174
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 125

def apply(entries)
  entries = normalize_entries(entries)
  if entries.empty?
    UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
      ::I18n.t("vdhm.messages.no_entries", default: "No hosts entries configured."))
    return 0
  end

  base    = read
  newline = detect_newline(base)
  cleaned = remove_block_from(base)
  cleaned = ensure_trailing_newline(cleaned, newline)

  existing_map = pairs_to_hash(list_pairs(:current))

  added = {}
  updated = {}
  unchanged = {}

  entries.each do |fqdn, ip|
    prev = existing_map[fqdn]
    if prev.nil?
      added[fqdn] = ip
      existing_map[fqdn] = ip
    elsif prev == ip
      unchanged[fqdn] = ip
    else
      updated[fqdn] = { from: prev, to: ip }
      existing_map[fqdn] = ip
    end
  end

  if added.empty? && updated.empty?
    UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
      ::I18n.t("vdhm.messages.no_change", default: "Nothing to apply. Already up-to-date."))
    return existing_map.size
  end

  merged  = normalize_entries(existing_map)
  content = cleaned + compose_block(merged, newline: newline)
  write(content)

  UiHelpers.say(@ui, "#{UiHelpers.e(:success)} " +
    ::I18n.t("vdhm.messages.applied", default: "Hosts entries applied."))
  UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
    ::I18n.t("vdhm.messages.apply_summary",
            default: "Added: %{a}, Updated: %{u}, Unchanged: %{s}",
            a: added.size, u: updated.size, s: unchanged.size))
  merged.size
end

#block_markersObject



63
64
65
66
67
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 63

def block_markers
  start = "# >>> #{block_name} (managed) >>>"
  stop  = "# <<< #{block_name} (managed) <<<"
  [start, stop]
end

#block_nameObject



59
60
61
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 59

def block_name
  "vagrant-docker-hosts-manager #{@owner_id}"
end

#compose_block(entries, newline: "\n") ⇒ Object



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 74

def compose_block(entries, newline: "\n")
  start, stop = block_markers
  ts = begin
         Time.now.utc.iso8601
       rescue StandardError
         Time.now.utc.to_s
       end

  header = [
    start,
    "# Managed by Vagrant - do not edit manually",
    "# Timestamp: #{ts}"
  ]
  body = entries.map { |d, ip| "#{ip} #{d}" }

  (header + body + [stop]).join(newline) + newline
end

#detect_newline(str) ⇒ Object



69
70
71
72
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 69

def detect_newline(str)
  return "\r\n" if Gem.win_platform?
  str.include?("\r\n") ? "\r\n" : "\n"
end

#each_managed_entry(scope = :all) ⇒ Object



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 340

def each_managed_entry(scope = :all)
  # A single scanner powers both current-owner and all-owner cleanup paths
  # so marker parsing rules stay identical.
  return enum_for(:each_managed_entry, scope) unless block_given?

  content = read
  return if content.to_s.empty?

  in_block = false
  owner    = nil

  content.each_line.with_index(1) do |raw, idx|
    line = raw.delete_suffix("\n").delete_suffix("\r")

    if (m = line.match(BLOCK_START_RE))
      in_block = true
      owner    = m[1].to_s.strip
      UiHelpers.debug(@ui, "start block(owner=#{owner}) at line #{idx}")
      next
    end

    if line.match?(BLOCK_STOP_RE)
      UiHelpers.debug(@ui, "stop  block(owner=#{owner}) at line #{idx}") if in_block
      in_block = false
      owner    = nil
      next
    end

    next unless in_block
    next unless scope == :all || owner == @owner_id

    if (m = line.match(ENTRY_RE))
      UiHelpers.debug(@ui, "  ip line: #{m[1]} #{m[2]} (owner=#{owner})")
      yield m[1], m[2], owner
    end
  end
end

#elevated?Boolean

Returns:

  • (Boolean)


408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 408

def elevated?
  if Gem.win_platform?
    cmd = %q{
      (New-Object Security.Principal.WindowsPrincipal(
        [Security.Principal.WindowsIdentity]::GetCurrent()
      )).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    }.strip
    Verbose.log("powershell -Command (check administrator role)")
    out, _err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd)
    st.success? && out.to_s.strip.downcase == "true"
  else
    begin
      Process.euid == 0
    rescue StandardError
      false
    end
  end
end

#ensure_trailing_newline(str, nl) ⇒ Object



111
112
113
114
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 111

def ensure_trailing_newline(str, nl)
  return "" if str.nil? || str.empty?
  str.end_with?(nl) ? str : (str + nl)
end

#entries_in_blocks(scope = :current) ⇒ Object



385
386
387
388
389
390
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 385

def entries_in_blocks(scope = :current)
  each_managed_entry(scope).with_object({}) do |(ip, fqdn, _owner), out|
    arr = (out[fqdn] ||= [])
    arr << ip unless arr.include?(ip)
  end
end

#list_pairs(scope = :all) ⇒ Object



378
379
380
381
382
383
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 378

def list_pairs(scope = :all)
  pairs = []
  each_managed_entry(scope) { |ip, fqdn, owner| pairs << [ip, fqdn, owner] }
  UiHelpers.debug(@ui, "list_pairs found #{pairs.length} pair(s)")
  pairs
end

#normalize_entries(entries) ⇒ Object



103
104
105
106
107
108
109
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 103

def normalize_entries(entries)
  entries
    .each_with_object({}) { |(d, ip), h| h[d.to_s.strip] = ip.to_s.strip }
    .reject { |d, ip| d.empty? || ip.empty? }
    .sort_by { |d, _ip| d }
    .to_h
end

#override_pathObject



30
31
32
33
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 30

def override_path
  p = ENV["VDHM_HOSTS_PATH"].to_s.strip
  p.empty? ? nil : p
end

#pairs_to_hash(pairs) ⇒ Object



92
93
94
95
96
97
98
99
100
101
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 92

def pairs_to_hash(pairs)
  h = {}
  pairs.each do |ip, fqdn, _owner|
    ip   = ip.to_s.strip
    fqdn = fqdn.to_s.strip
    next if ip.empty? || fqdn.empty?
    h[fqdn] = ip
  end
  h
end

#path_candidatesObject



35
36
37
38
39
40
41
42
43
44
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 35

def path_candidates
  # VDHM_HOSTS_PATH overrides the default location on every platform
  # (used by tests and power users); honor it before anything else.
  return [override_path] if override_path
  return [POSIX_PATH] unless Gem.win_platform?

  # Sysnative lets a 32-bit Ruby process reach the real System32 hosts
  # file on 64-bit Windows; keep it before System32/SysWOW64.
  [WIN_SYSNATIVE_PATH, WIN_SYS32_PATH, WIN_SYSWOW64_PATH]
end

#printable_pathObject



54
55
56
57
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 54

def printable_path
  p = real_path
  Gem.win_platform? ? p.tr("/", "\\") : p
end

#readObject



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 274

def read
  # Hosts files are often edited by Windows tools with BOMs or legacy
  # encodings; normalize to UTF-8 before parsing managed blocks.
  pth = real_path
  UiHelpers.debug(@ui, "read(#{pth})")

  data = nil

  begin
    data = File.binread(pth)
    UiHelpers.debug(@ui, "File.binread ok, bytes=#{data.bytesize}, encoding=#{data.encoding}")
  rescue StandardError => e
    UiHelpers.debug(@ui, "File.binread error: #{e.class}: #{e.message}")
    data = nil
  end

  if (data.nil? || data.empty?) && Gem.win_platform?
    ps_path = pth.gsub("'", "''")
    ps_cmd  = "Get-Content -LiteralPath '#{ps_path}' -Raw"
    Verbose.log("powershell -Command #{ps_cmd}")
    out, err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd)
    if st.success?
      data = out
      UiHelpers.debug(@ui, "Fallback PS read ok, bytes=#{data.bytesize}, encoding=#{data.encoding}")
    else
      UiHelpers.debug(@ui, "Fallback PS read failed: #{err}")
    end
  end

  return "" if data.nil?

  data = data.dup
  data.force_encoding(Encoding::BINARY)

  if data.start_with?("\xEF\xBB\xBF".b)
    UiHelpers.debug(@ui, "BOM detected, stripping")
    data = data.byteslice(3, data.bytesize - 3) || "".b
  end

  begin
    data = data.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
    UiHelpers.debug(@ui, "Transcoded to UTF-8 (direct), encoding=#{data.encoding}")
  rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
    UiHelpers.debug(@ui, "Direct UTF-8 encode failed: #{e.class}: #{e.message}, trying Windows-1252")
    begin
      data = data
             .force_encoding("Windows-1252")
             .encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
      UiHelpers.debug(@ui, "Transcoded via Windows-1252 -> UTF-8, encoding=#{data.encoding}")
    rescue StandardError => e2
      UiHelpers.debug(@ui, "Windows-1252 fallback failed: #{e2.class}: #{e2.message}")
      data = data.force_encoding(Encoding::UTF_8)
    end
  end

  if UiHelpers.debug_enabled?
    head = data.lines.first(8) rescue []
    UiHelpers.debug(@ui, "Head(8):\n" + head.join)
  end

  data
rescue StandardError => e
  UiHelpers.debug(@ui, "read() fatal: #{e.class}: #{e.message}")
  ""
end

#real_pathObject



46
47
48
49
50
51
52
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 46

def real_path
  cand = path_candidates
  UiHelpers.debug(@ui, "Candidates: #{cand.inspect}")
  found = cand.find { |p| File.exist?(p) } || cand.first
  UiHelpers.debug(@ui, "Selected path: #{found}")
  found
end

#remove!Boolean

Removes the current owner's managed hosts block.

Returns:

  • (Boolean)

    Whether a block was removed.



232
233
234
235
236
237
238
239
240
241
242
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 232

def remove!
  content = read
  newc = remove_block_from(content)
  removed = (newc != content)
  write(newc) if removed

  UiHelpers.say(@ui, removed ?
    "#{UiHelpers.e(:broom)} " + ::I18n.t("vdhm.messages.cleaned", default: "Managed hosts entries removed.") :
    "#{UiHelpers.e(:info)} "  + ::I18n.t("vdhm.messages.nothing_to_clean", default: "Nothing to clean."))
  removed
end

#remove_all_managed!Boolean

Removes every block managed by this plugin, regardless of owner.

Returns:

  • (Boolean)

    Whether any managed block was removed.



247
248
249
250
251
252
253
254
255
256
257
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 247

def remove_all_managed!
  content = read
  newc = strip_managed_blocks(content)
  removed = (newc != content)
  write(newc) if removed

  UiHelpers.say(@ui, removed ?
    "#{UiHelpers.e(:broom)} " + ::I18n.t("vdhm.messages.cleaned_all", default: "All managed hosts blocks removed.") :
    "#{UiHelpers.e(:info)} "  + ::I18n.t("vdhm.messages.nothing_to_clean", default: "Nothing to clean."))
  removed
end

#remove_block_from(content) ⇒ Object



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 392

def remove_block_from(content)
  start, stop = block_markers
  removing = false
  content.lines.reject do |line|
    if line.start_with?(start)
      removing = true
      true
    elsif line.start_with?(stop)
      removing = false
      true
    else
      removing
    end
  end.join
end

#remove_entries!(ips: [], domains: []) ⇒ Integer

Removes matching entries from the current owner's managed block.

With no filters this falls back to removing the whole current-owner block.

Parameters:

  • ips (Array<String>) (defaults to: [])

    IP addresses to remove.

  • domains (Array<String>) (defaults to: [])

    Domain names to remove.

Returns:

  • (Integer)

    Number of removed entries, or 1 when a whole block was removed.



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 183

def remove_entries!(ips: [], domains: [])
  ips     = Array(ips).map(&:to_s).reject(&:empty?)
  domains = Array(domains).map(&:to_s).reject(&:empty?)
  return (remove! ? 1 : 0) if ips.empty? && domains.empty?

  pairs  = list_pairs(:current)
  before = pairs.length
  return 0 if before.zero?

  filtered = pairs.reject do |ip, fqdn, _|
    ips.include?(ip.to_s) || domains.include?(fqdn.to_s)
  end

  removed_count = before - filtered.length
  if removed_count <= 0
    UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
      ::I18n.t("vdhm.messages.remove_none",
        default: "No matching entry to remove."))
    return 0
  end

  base    = read
  newline = detect_newline(base)
  cleaned = remove_block_from(base)

  if filtered.empty?
    write(cleaned)
  else
    cleaned = ensure_trailing_newline(cleaned, newline)
    remaining_map = normalize_entries(pairs_to_hash(filtered))
    content = cleaned + compose_block(remaining_map, newline: newline)
    write(content)
  end

  UiHelpers.say(
    @ui,
    "#{UiHelpers.e(:broom)} " +
      ::I18n.t(
        "vdhm.messages.removed_count",
        default: "%{count} entries removed.",
        count: removed_count
      )
  )
  removed_count
end

#strip_managed_blocks(content) ⇒ Object



259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 259

def strip_managed_blocks(content)
  removing = false
  content.lines.reject do |line|
    if line.match?(BLOCK_START_RE)
      removing = true
      true
    elsif line.match?(BLOCK_STOP_RE)
      removing = false
      true
    else
      removing
    end
  end.join
end

#write(content) ⇒ Object



427
428
429
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 427

def write(content)
  Gem.win_platform? ? write_windows(content) : write_posix(content)
end

#write_posix(content) ⇒ Object



431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 431

def write_posix(content)
  File.binwrite(real_path, content)
rescue Errno::EACCES
  tf = Tempfile.new("vdhm-hosts")
  begin
    tf.binmode
    tf.write(content); tf.flush
    Verbose.log("sudo", "cp", tf.path, real_path)
    system("sudo", "cp", tf.path, real_path) || raise("sudo copy failed")
  ensure
    tf.close!
  end
end

#write_windows(content) ⇒ Object



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/vagrant-docker-hosts-manager/util/hosts_file.rb', line 445

def write_windows(content)
  # Try a direct write first; fall back to UAC only when the hosts file
  # rejects it. Base64/UTF-16LE keeps PowerShell quoting predictable.
  b64  = Base64.strict_encode64(
    content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
  )
  dest = real_path.gsub("'", "''")

  ps = <<~POW
    $ErrorActionPreference = "Stop"
    try {
      $bytes = [System.Convert]::FromBase64String('#{b64}')
      [System.IO.File]::WriteAllBytes('#{dest}', $bytes)
      exit 0
    } catch {
      exit 1
    }
  POW
  encoded = Base64.strict_encode64(ps.encode("UTF-16LE"))

  Verbose.log("powershell -EncodedCommand (write hosts file: #{real_path})")
  _out, _err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
  return if st.success?

  elev_ps = <<~POW
    $ErrorActionPreference = 'Stop'
    try {
      $p = Start-Process PowerShell -Verb RunAs -Wait -PassThru -ArgumentList '-NonInteractive','-NoProfile','-EncodedCommand','#{encoded}'
      exit $p.ExitCode
    } catch {
      exit 1
    }
  POW
  elev_encoded = Base64.strict_encode64(elev_ps.encode("UTF-16LE"))
  Verbose.log("powershell -EncodedCommand (elevated write hosts file via RunAs: #{real_path})")
  system("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", elev_encoded) ||
    raise("elevated write failed")
end