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 +4 -1
- package/UNDO-PROMISE.md +3 -2
- package/package.json +1 -1
- package/src/deploy.js +12 -1
- package/src/remote-fs.js +41 -2
- package/src/restore.js +12 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|