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 "path
epoch" 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) fehltestatkomplett: die leere Substitution wurde als Epoche 0 geparst undtwin statuszeigte 1970 statt "unbekannt".date -rkennt 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, "path
md5" 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
- .md5_paths(host, paths) ⇒ Object
-
.mkdir_p(host, dir) ⇒ Object
Create a directory on the remote side (mkdir -p equivalent).
- .parse_preflight(out) ⇒ Object
- .preflight(host) ⇒ Object
-
.reachable?(host) ⇒ Boolean
Non-interactive reachability probe (BatchMode: never asks for a password).
- .remote?(target) ⇒ Boolean
-
.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).
-
.split(target) ⇒ Object
"user@host:/path" → ["user@host", "/path"].
- .stat_paths(host, paths) ⇒ Object
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).
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
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 |