Module: Twin::Remote

Defined in:
lib/twin/remote.rb

Overview

Remote (ssh) targets, written exactly as rsync understands them: "user@host:/path" or "host:/path". A target counts as remote when a colon appears before the first slash. Sources stay local — twin pushes.

Constant Summary collapse

SSH_OPTS =
["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"].freeze
STAT_SCRIPT =

Stat many paths in one ssh round-trip. Paths go over stdin (one per line), the remote loop answers "pathepoch" or "path-" for missing ones. Tries BSD stat, then GNU, then BusyBox date -r — covers macOS, Linux and OpenWrt. Auf dem Brume (25.08.2026) fehlte stat komplett: die leere Substitution wurde als Epoche 0 geparst und twin status zeigte 1970 statt "unbekannt". date -r kennt kein --; verkraftbar, weil hier nur absolute Zielpfade ankommen. Returns => Time or nil-if-missing, or nil when ssh itself failed. POSIX sh, and it must stay free of single quotes: it is handed to the remote side wrapped in '...' so that any login shell passes it through literally. The usual POSIX escape for an embedded quote (''') is parsed differently by fish, so the rule here is simply not to need it — hence double quotes around the printf formats.

<<~SH.freeze
  while IFS= read -r p; do
    if [ -e "$p" ]; then
      m=$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p" 2>/dev/null || date -r "$p" +%s)
      printf "%s\t%s\n" "$p" "$m"
    else
      printf "%s\t-\n" "$p"
    fi
  done
SH
MD5_SCRIPT =

Checksum many paths in one ssh round-trip, same shape as stat_paths: paths over stdin, "pathmd5" back, "-" for anything that is not a regular file. Tries BSD md5 first, then md5sum (GNU, BusyBox) — covers macOS, Linux and OpenWrt. md5sum prints "hash path"; the parameter expansion keeps only the first word. Same single-quote rule as STAT_SCRIPT, and MD5 is drift detection here, not cryptography. Returns => hex or nil-if-unreadable, or nil when ssh itself failed.

<<~SH.freeze
  while IFS= read -r p; do
    if [ -f "$p" ]; then
      m=$(md5 -q "$p" 2>/dev/null || md5sum "$p" 2>/dev/null)
      m=${m%% *}
      printf "%s\t%s\n" "$p" "$m"
    else
      printf "%s\t-\n" "$p"
    fi
  done
SH
PREFLIGHT_SCRIPT =

Which of the tools twin relies on exist on the far side? One ssh round-trip per host, for twin doctor. rsync carries the sync itself; stat/date feed the batched mtime round; md5/md5sum feed the content check. Same single-quote rule as the other batch scripts. Returns => present?, or nil when ssh itself failed.

<<~SH.freeze
  for t in rsync stat date md5 md5sum; do
    if command -v "$t" >/dev/null 2>&1; then
      printf "%s\tok\n" "$t"
    else
      printf "%s\t-\n" "$t"
    fi
  done
SH

Class Method Summary collapse

Class Method Details

.md5_paths(host, paths) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/twin/remote.rb', line 99

def md5_paths(host, paths)
  return {} if paths.empty?
  raise "MD5_SCRIPT must not contain single quotes" if MD5_SCRIPT.include?("'")

  out, _err, status = Open3.capture3(
    "ssh", *SSH_OPTS, host, "/bin/sh -c '#{MD5_SCRIPT}'",
    stdin_data: paths.join("\n") + "\n"
  )
  return nil unless status.success?

  result = {}
  out.each_line do |line|
    path, sum = line.chomp.split("\t", 2)
    next unless path && sum
    result[path] = sum.match?(/\A\h{32}\z/) ? sum.downcase : nil
  end
  result
rescue Errno::ENOENT
  nil # ssh not installed
end

.mkdir_p(host, dir) ⇒ Object

Create a directory on the remote side (mkdir -p equivalent).



156
157
158
159
# File 'lib/twin/remote.rb', line 156

def mkdir_p(host, dir)
  _out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "mkdir", "-p", shellesc(dir))
  status.success?
end

.parse_preflight(out) ⇒ Object



145
146
147
148
149
150
151
152
153
# File 'lib/twin/remote.rb', line 145

def parse_preflight(out)
  result = {}
  out.each_line do |line|
    tool, state = line.chomp.split("\t", 2)
    next unless tool && state
    result[tool] = state == "ok"
  end
  result
end

.preflight(host) ⇒ Object



135
136
137
138
139
140
141
142
143
# File 'lib/twin/remote.rb', line 135

def preflight(host)
  raise "PREFLIGHT_SCRIPT must not contain single quotes" if PREFLIGHT_SCRIPT.include?("'")

  out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "/bin/sh -c '#{PREFLIGHT_SCRIPT}'")
  return nil unless status.success?
  parse_preflight(out)
rescue Errno::ENOENT
  nil # ssh not installed
end

.reachable?(host) ⇒ Boolean

Non-interactive reachability probe (BatchMode: never asks for a password).

Returns:

  • (Boolean)


23
24
25
# File 'lib/twin/remote.rb', line 23

def reachable?(host)
  system("ssh", *SSH_OPTS, host, "true", out: File::NULL, err: File::NULL)
end

.remote?(target) ⇒ Boolean

Returns:

  • (Boolean)


12
13
14
# File 'lib/twin/remote.rb', line 12

def remote?(target)
  %r{\A[^/]+:}.match?(target.to_s)
end

.shellesc(s) ⇒ Object

Escape one argument for the remote shell (ssh joins args with spaces and hands the string to a shell — local exec-style arrays don't protect it).



163
164
165
# File 'lib/twin/remote.rb', line 163

def shellesc(s)
  "'" + s.gsub("'", "'\\\\''") + "'"
end

.split(target) ⇒ Object

"user@host:/path" → ["user@host", "/path"]



17
18
19
20
# File 'lib/twin/remote.rb', line 17

def split(target)
  host, path = target.to_s.split(":", 2)
  [host, path.to_s]
end

.stat_paths(host, paths) ⇒ Object



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/twin/remote.rb', line 51

def stat_paths(host, paths)
  return {} if paths.empty?
  # Explicitly through /bin/sh. ssh hands the command to the *login shell*
  # on the far side, and that is not necessarily POSIX: on a machine whose
  # shell is fish, the bare script dies on "while IFS= read -r p; do" and
  # twin read the failure as "host not answering" — every ssh target showed
  # as :unreachable in status and the picker, for as long as SSH targets
  # existed (#8, 2026-07-17). Found and fixed 2026-08-25.
  raise "STAT_SCRIPT must not contain single quotes" if STAT_SCRIPT.include?("'")

  out, _err, status = Open3.capture3(
    "ssh", *SSH_OPTS, host, "/bin/sh -c '#{STAT_SCRIPT}'",
    stdin_data: paths.join("\n") + "\n"
  )
  return nil unless status.success?

  result = {}
  out.each_line do |line|
    path, mtime = line.chomp.split("\t", 2)
    next unless path && mtime
    # Nur echte Epochen als Zeit werten. Scheitert die ganze stat-Kette,
    # ist das Feld leer — das ist "unbekannt", nicht 1970.
    result[path] = mtime.match?(/\A\d+\z/) ? Time.at(mtime.to_i) : nil
  end
  result
rescue Errno::ENOENT
  nil # ssh not installed
end