shipback-sftp 0.2.0 → 0.2.1

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.
package/README.md CHANGED
@@ -26,6 +26,8 @@ if undo ever breaks one of them, that's the first bug I fix.
26
26
  - **Restores can be undone too.** A restore backs up what it replaces, the same way a deploy does.
27
27
  - **One run at a time.** A deploy or restore holds a lock on the site while it writes. A second one is turned away
28
28
  with nothing changed, so an agent and a person can't deploy over each other.
29
+ - **Stays inside the site.** Deploy and undo won't write through a symlink or over a special file on the server,
30
+ so a symlinked folder can't carry a write somewhere else.
29
31
  - **Safe for agents.** The MCP server has no switch to delete server files or skip the backup. A deploy
30
32
  only runs the exact plan the agent previewed: if a file changed in between, it refuses.
31
33
  - **Spots the drift.** `--pull --dry-run` shows what changed on the server since your last deploy, so
@@ -113,7 +115,8 @@ step, keeping its permissions. Listings hide part files, and the next deploy or
113
115
 
114
116
  While a deploy, upload, restore or prune writes, it holds `.shipback-lock` in the remote root and refreshes it every
115
117
  30 seconds. The lock is never deployed, pulled or listed. A lock goes stale, and the next run takes it over, when its
116
- process on this computer has ended or nothing has refreshed it for 2 minutes. So a run that was cut off never leaves
118
+ process on this computer has ended or nothing has refreshed it for 2 minutes. If the system has reused that process
119
+ ID, the lock just waits out the 2 minutes; a run that's still going is never mistaken for a finished one. So a run that was cut off never leaves
117
120
  a site locked for long.
118
121
 
119
122
  `--restore` reads that record and checks each live file first:
package/UNDO-PROMISE.md CHANGED
@@ -24,7 +24,7 @@ and Linux, and compare every byte on the server, not a sample.
24
24
  | **P10** | All of this works through a real SFTP server, and replaced files keep their permissions. |
25
25
  | **P11** | A dropped connection fails the deploy out loud and tells you how to undo it. It never stops in silence. |
26
26
  | **P12** | Only one deploy or undo writes to a site at a time. A second one is turned away without changing anything, so two runs can never get mixed into one backup. A lock left behind by a run that was cut off clears itself. |
27
- | **P13** | Undo never touches anything outside the site folder, even if a backup record has been tampered with to say it should. A record that can't be read is refused, never guessed at. |
27
+ | **P13** | Undo never touches anything outside the site folder, even if a backup record has been tampered with to say it should, and neither undo nor deploy writes through a symlink or over a special file on the server. A record that can't be read is refused, never guessed at. |
28
28
 
29
29
  These have also been run on a real cPanel host, including cutting the connection halfway through a deploy and running
30
30
  two deploys at once.
@@ -41,7 +41,8 @@ If undo ever fails to do one of those things, [open an "Undo didn't work" issue]
41
41
 
42
42
  - **Deploys made with `--no-backup`.** They have nothing to undo from. The agent tools can't make them.
43
43
  - **Backups that are gone.** Undo needs that deploy's `_backup-…` folder. `--prune` will remove old ones if you ask, and so can you or your host.
44
- - **Anything outside the site folder:** databases, server settings, cron jobs, email.
44
+ - **Anything outside the site folder:** databases, server settings, cron jobs, email. The site folder itself may be a
45
+ symlink (cPanel's `www` → `public_html` is normal), and Shipback follows that one. It won't follow any symlink inside it.
45
46
  - **Backups made before 0.2.** Those still restore, but they don't carry the checksums behind P4 and P8.
46
47
  - **File dates and owners.** Restored files get the current time. A file brought back after a deploy deleted it gets the server's default permissions.
47
48
  - **Losing the hosting account.** Backups sit on the same server as the site. Keep your host's own backups too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipback-sftp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SFTP deploys to shared hosting that you, a script or an AI agent can undo: backup before every overwrite, one-command restore that won't clobber live edits, and an MCP server.",
5
5
  "keywords": [
6
6
  "sftp",
package/src/deploy.js CHANGED
@@ -5,7 +5,7 @@ const posix = path.posix;
5
5
  const { scanLocal, loadIgnore, dropIgnored, sha256File } = require('./manifest');
6
6
  const { plan: makePlan, resolveVerify, MANIFEST_NAME, BACKUP_PREFIX } = require('./diff');
7
7
  const { backupFiles, makeStamp, freeBackupDir, writeBackupInfo } = require('./backup');
8
- const { writeLive } = require('./remote-fs');
8
+ const { writeLive, outsideReason } = require('./remote-fs');
9
9
  const { withLock } = require('./lock');
10
10
 
11
11
  const isBackupDir = name => name.startsWith(BACKUP_PREFIX);
@@ -15,6 +15,15 @@ async function readRemoteManifest(remoteFs, root) {
15
15
  catch { return null; }
16
16
  }
17
17
 
18
+ /** Throw before anything changes if any path would write through a symlink or over a special file. */
19
+ async function refuseOutside(remoteFs, root, paths) {
20
+ const folders = new Map();
21
+ for (const p of paths) {
22
+ const why = await outsideReason(remoteFs, root, p, folders);
23
+ if (why) throw new Error(`Refused: ${why}. Nothing was changed. Ignore that path in sites.json or .deployignore, or replace the symlink with a real folder.`);
24
+ }
25
+ }
26
+
18
27
  /** Preview only: scan both sides and return the plan. */
19
28
  async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, ignore, onEvent = () => {} }) {
20
29
  const root = remoteRoot.replace(/\/$/, '');
@@ -27,6 +36,7 @@ async function previewDeploy({ localDir, remoteRoot, remoteFs, mirror = false, i
27
36
  const remoteListing = rootStat ? dropIgnored(await remoteFs.listRecursive(root, { skipDir: isBackupDir }), await loadIgnore(localDir, ignore)) : [];
28
37
  const remoteManifest = rootStat ? await readRemoteManifest(remoteFs, root) : null;
29
38
  const plan = makePlan({ local, remoteManifest, remoteListing, mirror });
39
+ if (rootStat) await refuseOutside(remoteFs, root, [...plan.upload.map(u => u.path), ...plan.delete]);
30
40
  if (plan.verify.length) await resolveVerify(plan, { readFile: p => remoteFs.readFile(`${root}/${p}`), onEvent });
31
41
  onEvent({ type: 'plan', plan, localCount: local.length, remoteCount: remoteListing.length, remoteExists: !!rootStat });
32
42
  return { local, plan, remoteExists: !!rootStat };
@@ -114,6 +124,7 @@ async function uploadNow({ files, remoteDir, remoteFs, backup = true, now = new
114
124
  const summary = { uploaded: [], backedUp: [], backupDir: null, bytes: 0 };
115
125
  try {
116
126
  await remoteFs.mkdirp(root);
127
+ await refuseOutside(remoteFs, root, files.map(f => f.name));
117
128
  if (backup && files.length) {
118
129
  const backupDir = await freeBackupDir(remoteFs, root, makeStamp(now));
119
130
  await writeBackupInfo({ remoteFs, backupDir, info: { action: 'upload', at: now.toISOString(), status: 'saving', overwritten: [], added: [], deleted: [] } });
package/src/remote-fs.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // stat(p) → { size, mtime(ms), isDir } | null
4
4
  // listRecursive(dir, { skipDir }) → [{ path (posix, relative to dir), size, mtime(ms) }] (files only)
5
5
  // mkdirp(p) · readFile(p) → Buffer · writeFile(p, Buffer) · uploadFile(localPath, p)
6
+ // lstat(p) → { isDir, isFile, isLink } | null (does not follow symlinks)
6
7
  // rename(a, b) · replace(tmp, dest) · remove(p) · rmdir(p) · close()
7
8
  //
8
9
  // Live files are never written in place: the new copy goes to partPath(dest) and replace() swaps it in, so a
@@ -21,7 +22,15 @@ const isHidden = rel => isPart(rel) || rel === LOCK_NAME; // Shipback's own work
21
22
  // ---------------------------------------------------------------- MemoryRemoteFs (tests)
22
23
  class MemoryRemoteFs {
23
24
  // failOn(p): throw before touching p · tornOn(p): write half of p, then throw (a dropped connection)
24
- constructor() { this.files = new Map(); this.dirs = new Set(['/']); this.failOn = null; this.tornOn = null; }
25
+ // odd: path → 'link' | 'fifo', for paths a test says are a symlink or a special file
26
+ constructor() { this.files = new Map(); this.dirs = new Set(['/']); this.failOn = null; this.tornOn = null; this.odd = new Map(); }
27
+ async lstat(p) {
28
+ p = posix.normalize(p);
29
+ const odd = this.odd.get(p);
30
+ if (odd) return { isDir: false, isFile: false, isLink: odd === 'link' };
31
+ const st = await this.stat(p);
32
+ return st && { isDir: st.isDir, isFile: !st.isDir, isLink: false };
33
+ }
25
34
  _check(p) { if (this.failOn && this.failOn(p)) throw new Error(`Simulated failure on ${p}`); }
26
35
  async stat(p) {
27
36
  p = posix.normalize(p);
@@ -143,6 +152,10 @@ class SftpRemoteFs {
143
152
  try { const a = await this._p(cb => this.sftp.stat(p, cb)); return { size: a.size, mtime: a.mtime * 1000, isDir: a.isDirectory(), mode: a.mode }; }
144
153
  catch (e) { if (e.code === 2 || /No such file/i.test(e.message)) return null; throw e; }
145
154
  }
155
+ async lstat(p) {
156
+ try { const a = await this._p(cb => this.sftp.lstat(p, cb)); return { isDir: a.isDirectory(), isFile: a.isFile(), isLink: a.isSymbolicLink() }; }
157
+ catch (e) { if (e.code === 2 || /No such file/i.test(e.message)) return null; throw e; }
158
+ }
146
159
  async listRecursive(dir, { skipDir } = {}) {
147
160
  const out = [];
148
161
  const walk = async (abs, rel) => {
@@ -215,6 +228,32 @@ class SftpRemoteFs {
215
228
  async close() { try { this.client.end(); } catch { /* ignore */ } }
216
229
  }
217
230
 
231
+ /**
232
+ * Why writing or removing <root>/<rel> would not stay inside the site, or null if it's safe: every folder between
233
+ * the root and the file must be a real folder (a symlinked one would carry the write somewhere else), and the file
234
+ * itself a plain file or not there yet. The root itself may be a symlink (cPanel's www → public_html is normal).
235
+ * folders: a Map shared across one run, so each folder is looked at once.
236
+ */
237
+ async function outsideReason(remoteFs, root, rel, folders = new Map()) {
238
+ const segs = rel.split('/');
239
+ let cur = root.replace(/\/$/, '');
240
+ for (let i = 0; i < segs.length; i++) {
241
+ cur += '/' + segs[i];
242
+ const shown = segs.slice(0, i + 1).join('/');
243
+ const last = i === segs.length - 1;
244
+ if (!last && folders.has(cur)) { const why = folders.get(cur); if (why === 'missing') return null; if (why) return why; continue; }
245
+ const st = await remoteFs.lstat(cur);
246
+ if (!st) { if (!last) folders.set(cur, 'missing'); return null; } // nothing there yet: what gets made is a real folder
247
+ let why = null;
248
+ if (st.isLink) why = `${shown} is a symlink on the server, so writing there could reach outside the site`;
249
+ else if (!last && !st.isDir) why = `${shown} is not a folder on the server`;
250
+ else if (last && !st.isFile && !st.isDir) why = `${shown} is a special file on the server (not a plain file)`;
251
+ if (!last) folders.set(cur, why);
252
+ if (why) return why;
253
+ }
254
+ return null;
255
+ }
256
+
218
257
  /** Write a live file the safe way: to its part file first, then swapped in whole. Give { data } or { localPath }. */
219
258
  async function writeLive(remoteFs, dest, { data, localPath }) {
220
259
  const part = partPath(dest);
@@ -223,4 +262,4 @@ async function writeLive(remoteFs, dest, { data, localPath }) {
223
262
  await remoteFs.replace(part, dest);
224
263
  }
225
264
 
226
- module.exports = { PART_PREFIX, LOCK_NAME, partPath, isPart, isHidden, writeLive, MemoryRemoteFs, SftpRemoteFs };
265
+ module.exports = { PART_PREFIX, LOCK_NAME, partPath, isPart, isHidden, writeLive, outsideReason, MemoryRemoteFs, SftpRemoteFs };
package/src/restore.js CHANGED
@@ -23,7 +23,7 @@ const posix = require('node:path').posix;
23
23
  const { BACKUP_PREFIX, MANIFEST_NAME } = require('./diff');
24
24
  const { backupFiles, makeStamp, freeBackupDir, writeBackupInfo, readBackupInfo, BACKUP_INFO } = require('./backup');
25
25
  const { listBackups } = require('./prune');
26
- const { partPath, writeLive } = require('./remote-fs');
26
+ const { partPath, writeLive, outsideReason } = require('./remote-fs');
27
27
  const { withLock } = require('./lock');
28
28
 
29
29
  const sha256 = buf => crypto.createHash('sha256').update(buf).digest('hex');
@@ -97,9 +97,18 @@ async function restoreNow({ remoteRoot, remoteFs, name, dryRun = false, force =
97
97
  };
98
98
  const conflict = (p, why) => summary.conflicts.push({ path: p, why });
99
99
 
100
+ // never through a symlink or over a special file, even forced: that could reach outside the site
101
+ const folders = new Map(), unsafe = new Set();
102
+ const outside = async p => {
103
+ const why = await outsideReason(remoteFs, root, p, folders);
104
+ if (why) { unsafe.add(p); conflict(p, why); }
105
+ return !!why;
106
+ };
107
+
100
108
  // 1. what goes back: every file in the backup folder
101
109
  const puts = [];
102
110
  for (const f of (await remoteFs.listRecursive(dir)).filter(f => f.path !== BACKUP_INFO)) {
111
+ if (await outside(f.path)) continue;
103
112
  const data = await remoteFs.readFile(`${dir}/${f.path}`);
104
113
  if (was.has(f.path) && sha256(data) !== was.get(f.path)) { conflict(f.path, 'its copy in the backup folder is damaged (it no longer matches what was saved)'); continue; } // never put back, even forced
105
114
  const now_ = await live(f.path);
@@ -112,6 +121,7 @@ async function restoreNow({ remoteRoot, remoteFs, name, dryRun = false, force =
112
121
  // 2. what comes off: the files that run added
113
122
  const removes = [];
114
123
  if (info) for (const e of info.added) {
124
+ if (await outside(e.path)) continue;
115
125
  const now_ = await live(e.path);
116
126
  if (now_ === null) { summary.same.push(e.path); continue; }
117
127
  if (now_ === 'dir') { conflict(e.path, 'is a folder on the server now'); continue; }
@@ -132,6 +142,7 @@ async function restoreNow({ remoteRoot, remoteFs, name, dryRun = false, force =
132
142
  // part files a stopped run left behind (hidden from listings, but they hold the run's bytes)
133
143
  const sweepParts = async () => {
134
144
  for (const p of new Set([...left.keys(), ...deleted])) {
145
+ if (unsafe.has(p)) continue;
135
146
  const part = partPath(`${root}/${p}`);
136
147
  if (await remoteFs.stat(part)) await remoteFs.remove(part).catch(() => {});
137
148
  }