Skip to content

Commit 1479d95

Browse files
feat(security): directory path preservation in staging, SFTP shell-free mkdir, dot-dir share exclusion, and Stages 8-9 test expansion (60/60 passed)
1 parent 1766c8f commit 1479d95

10 files changed

Lines changed: 512 additions & 182 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Thumbs.db
2323

2424
# Test artifacts
2525
test-workspace/
26+
test-workspace*/
27+
test-workspace-*/
28+
test-integration-*/
2629
test.env
2730
clone-test/
2831
playground/

cloudsync-cli/src/cli/commands/commit.js

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,24 @@ const commitCommand = new Command('commit')
4141
return;
4242
}
4343

44-
const stagedFiles = readdirSync(stagingDir).filter(f => f !== 'index.json');
44+
const stagedFiles = readdirSync(stagingDir).filter(f => f !== 'index.json' && !f.endsWith('.tmp'));
4545

4646
if (stagedFiles.length === 0 && !options.amend) {
4747
failWith('Nothing to commit. Stage some files first: cloudsync stage <files>');
4848
return;
4949
}
5050

51+
// Load the staged-name -> original-path mapping recorded by `stage`.
52+
// Archive entries are named with the ORIGINAL relative path so a
53+
// rollback restores "data/sample.txt", not the flattened copy.
54+
let stagedMap = null;
55+
try {
56+
const idx = safeJsonParse(readFileSync(join(stagingDir, 'index.json'), 'utf8'), {});
57+
if (Array.isArray(idx.entries)) stagedMap = new Map(idx.entries.map(e => [e.staged, e.path]));
58+
} catch (_) { }
59+
const originalPathOf = (flatName) => (stagedMap && stagedMap.has(flatName)) ? stagedMap.get(flatName) : flatName;
60+
const commitFiles = stagedFiles.map(originalPathOf);
61+
5162
const commitMessage = (message && String(message).trim()) || '';
5263
if (!commitMessage) {
5364
failWith('Commit cancelled — message is required. Usage: cloudsync commit "<message>"');
@@ -96,7 +107,7 @@ const commitCommand = new Command('commit')
96107
// Create zip archive
97108
const archivePath = join(historyDir, `${commitId}.zip`);
98109
mkdirSync(historyDir, { recursive: true });
99-
await createStagedArchive(stagingDir, stagedFiles, archivePath);
110+
await createStagedArchive(stagingDir, stagedFiles, archivePath, originalPathOf);
100111

101112
// Apply AES-256-GCM encryption if requested
102113
let isEncrypted = false;
@@ -124,7 +135,7 @@ const commitCommand = new Command('commit')
124135
id: commitId,
125136
message: commitMessage,
126137
timestamp,
127-
files: stagedFiles,
138+
files: commitFiles,
128139
encrypted: isEncrypted,
129140
author: process.env.USER || process.env.USERNAME || 'unknown'
130141
};
@@ -162,7 +173,7 @@ const commitCommand = new Command('commit')
162173
}
163174

164175
// Display commit info
165-
logOperation('commit', `Committed: ${commitMessage}`, { files: stagedFiles, commitId });
176+
logOperation('commit', `Committed: ${commitMessage}`, { files: commitFiles, commitId });
166177
console.log(chalk.green('\nCommitted successfully!'));
167178
console.log(chalk.gray('-'.repeat(60)));
168179
console.log(chalk.cyan(` Commit ID: ${commitId}`));
@@ -179,7 +190,7 @@ function generateCommitId() {
179190
return `${timestamp}-${random}`;
180191
}
181192

182-
async function createStagedArchive(stagingDir, files, outputPath) {
193+
async function createStagedArchive(stagingDir, files, outputPath, originalPathOf) {
183194
return new Promise((resolve, reject) => {
184195
const output = createWriteStream(outputPath);
185196
const archive = new ZipArchive({ zlib: { level: 9 } });
@@ -189,11 +200,13 @@ async function createStagedArchive(stagingDir, files, outputPath) {
189200
archive.pipe(output);
190201

191202
files.forEach(f => {
192-
const safeName = f.replace(/[\\/]/g, '__');
193-
const stagedFilePath = join(stagingDir, safeName);
194-
const actualPath = existsSync(stagedFilePath) ? stagedFilePath : join(stagingDir, f);
195-
if (existsSync(actualPath)) {
196-
archive.file(actualPath, { name: f });
203+
// `f` is the flat staged filename; the archive entry is named with
204+
// the ORIGINAL relative path (via the staging index mapping) so
205+
// rollback extracts to the real location.
206+
const entryName = originalPathOf ? originalPathOf(f) : f;
207+
const stagedFilePath = join(stagingDir, f);
208+
if (existsSync(stagedFilePath)) {
209+
archive.file(stagedFilePath, { name: entryName });
197210
}
198211
});
199212

cloudsync-cli/src/cli/commands/share.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { Command } from 'commander';
1717
import chalk from 'chalk';
1818
import { existsSync, writeFileSync, mkdirSync, createReadStream, statSync, createWriteStream, rmSync } from 'fs';
19-
import { join, basename, normalize } from 'path';
19+
import { join, basename, resolve } from 'path';
2020
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
2121
import http from 'http';
2222
import url from 'url';
@@ -68,8 +68,9 @@ const shareCommand = new Command('share')
6868
return;
6969
}
7070

71-
// Resolve path (resolve to absolute, then normalize to defend against .. tricks)
72-
const targetPath = normalize(join(process.cwd(), sharePath));
71+
// resolve() handles both relative and absolute inputs correctly
72+
// (unlike join(), which mangles absolute paths by prefixing cwd)
73+
const targetPath = resolve(sharePath);
7374

7475
if (!existsSync(targetPath)) {
7576
failWith(`❌ Path not found: ${targetPath}`);
@@ -328,7 +329,17 @@ async function startShareServer(session, options, verbose) {
328329
try { res.end(); } catch (_) {}
329330
});
330331
archive.pipe(res);
331-
archive.directory(target, false);
332+
// Exclude dot-directories — sharing "." must never leak the
333+
// sender's .cloudsync/ internals (sessions, history, password hash)
334+
archive.directory(target, false, (entry) => {
335+
const rel = entry.name.replace(/^\.\//, '');
336+
const parts = rel.split('/');
337+
const first = parts[0] || '';
338+
if (first.startsWith('.') && first.length > 1) {
339+
return false; // drop the entire dot-directory subtree
340+
}
341+
return entry;
342+
});
332343
archive.finalize().catch((e) => {
333344
console.error('Finalize error:', e.message);
334345
});

cloudsync-cli/src/cli/commands/stage.js

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { Command } from 'commander';
1212
import chalk from 'chalk';
13-
import { existsSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync } from 'fs';
13+
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, statSync, renameSync, unlinkSync } from 'fs';
1414
import { join, relative, basename } from 'path';
1515
import { formatBytes } from '../../utils/helpers.js';
1616
import { safePath, isSafeFilename } from '../../utils/security.js';
@@ -115,14 +115,24 @@ const stageCommand = new Command('stage')
115115
}
116116
});
117117

118+
/**
119+
* Staged files are stored flat (subdirectory separators become "__") so the
120+
* staging dir stays a single level. The original relative path is recorded in
121+
* the staging index so commit can name archive entries correctly — otherwise
122+
* a rollback would restore "data__sample.txt" instead of "data/sample.txt".
123+
*/
124+
function flattenName(relPath) {
125+
return relPath.replace(/[\\/]/g, '__');
126+
}
127+
118128
function stageFile(filePath, stagedFiles, stagingDir, verbose, rejectedFiles, originalArg) {
119129
try {
120-
const relPath = originalArg || relative(process.cwd(), filePath);
121-
const safeName = relPath.replace(/[\\/]/g, '__');
130+
const relPath = (originalArg || relative(process.cwd(), filePath)).replace(/\\/g, '/');
131+
const safeName = flattenName(relPath);
122132
const stagedPath = join(stagingDir, safeName);
123133

124134
copyFileSync(filePath, stagedPath);
125-
stagedFiles.push(relPath);
135+
stagedFiles.push({ staged: safeName, path: relPath });
126136

127137
if (verbose) {
128138
console.log(chalk.green(` + ${relPath}`));
@@ -134,20 +144,34 @@ function stageFile(filePath, stagedFiles, stagingDir, verbose, rejectedFiles, or
134144
}
135145

136146
function showStagedFiles(stagingDir, verbose) {
137-
const files = readdirSync(stagingDir).filter(f => f !== 'index.json');
147+
// Prefer the index mapping (original paths) when present; fall back to
148+
// the flat directory listing for workspaces staged before the mapping.
149+
let display = null;
150+
try {
151+
const idx = JSON.parse(readFileSync(join(stagingDir, 'index.json'), 'utf8'));
152+
if (Array.isArray(idx.entries) && idx.entries.length > 0) {
153+
display = idx.entries.map(e => e.path);
154+
} else if (Array.isArray(idx.files)) {
155+
display = idx.files;
156+
}
157+
} catch (_) { }
158+
if (!display) {
159+
display = readdirSync(stagingDir).filter(f => f !== 'index.json');
160+
}
138161

139162
console.log(chalk.cyan('\nStaged Files:'));
140163
console.log(chalk.gray('-'.repeat(40)));
141164

142-
if (files.length === 0) {
165+
if (display.length === 0) {
143166
console.log(chalk.yellow(' No files staged'));
144167
console.log(chalk.gray('\n Usage:'));
145168
console.log(chalk.gray(' cloudsync stage <files...> # Stage specific files'));
146169
console.log(chalk.gray(' cloudsync stage --all # Stage all'));
147170
} else {
148-
files.forEach(f => {
171+
display.forEach(f => {
149172
try {
150-
const stat = statSync(join(stagingDir, f));
173+
const flat = flattenName(f);
174+
const stat = statSync(join(stagingDir, flat));
151175
const size = formatBytes(stat.size);
152176
console.log(chalk.green(' + ') + chalk.white(f) + chalk.gray(` (${size})`));
153177
} catch (e) {
@@ -156,27 +180,58 @@ function showStagedFiles(stagingDir, verbose) {
156180
});
157181

158182
console.log(chalk.gray('-'.repeat(40)));
159-
console.log(chalk.gray(` ${files.length} file(s) staged`));
183+
console.log(chalk.gray(` ${display.length} file(s) staged`));
160184
}
161185
}
162186

163187
function saveStagedIndex(files, verbose) {
164-
const indexFile = join(process.cwd(), '.cloudsync', 'staging', 'index.json');
165-
// Atomic write: temp + rename — prevents races with concurrent stage invocations
166-
const tmp = indexFile + '.tmp';
188+
const stagingDir = join(process.cwd(), '.cloudsync', 'staging');
189+
const indexFile = join(stagingDir, 'index.json');
190+
191+
// Build the staged-name -> original-path mapping. Union with any entries
192+
// already recorded (concurrent stage invocations), then intersect with the
193+
// files that actually exist on disk right now so unstage removals stick.
194+
const byStaged = new Map();
195+
for (const f of files) {
196+
if (f && typeof f === 'object') byStaged.set(f.staged, f.path);
197+
}
198+
try {
199+
const existing = JSON.parse(readFileSync(indexFile, 'utf8'));
200+
if (Array.isArray(existing.entries)) {
201+
for (const e of existing.entries) byStaged.set(e.staged, e.path);
202+
}
203+
// Legacy index format (flat string list, pre-mapping) — carry over as-is
204+
if (Array.isArray(existing.files)) {
205+
for (const f of existing.files) byStaged.set(f, f);
206+
}
207+
} catch (_) { /* no index yet */ }
208+
209+
let onDisk;
210+
try {
211+
onDisk = new Set(readdirSync(stagingDir).filter(f => f !== 'index.json' && !f.endsWith('.tmp')));
212+
} catch (_) {
213+
onDisk = null;
214+
}
215+
216+
const entries = [...byStaged.entries()]
217+
.map(([staged, path]) => ({ staged, path }))
218+
.filter(e => onDisk === null || onDisk.has(e.staged));
219+
220+
const tmp = indexFile + `.${process.pid}.tmp`;
167221
writeFileSync(tmp, JSON.stringify({
168-
files,
222+
entries,
223+
files: entries.map(e => e.path), // backward-compatible flat view
169224
timestamp: new Date().toISOString()
170225
}, null, 2));
171226
try {
172227
renameSync(tmp, indexFile);
173228
} catch (e) {
174-
// Fallback: just copy if rename across mount fails
175-
writeFileSync(indexFile, JSON.stringify({ files, timestamp: new Date().toISOString() }, null, 2));
229+
// Fallback: direct write if rename fails (e.g. across mounts)
230+
writeFileSync(indexFile, JSON.stringify({ entries, files: entries.map(e => e.path), timestamp: new Date().toISOString() }, null, 2));
176231
try { unlinkSync(tmp); } catch (_) { }
177232
}
178233

179-
if (verbose) console.log(chalk.gray(`Staging index saved`));
234+
if (verbose) console.log(chalk.gray(`Staging index saved (${entries.length} files)`));
180235
}
181236

182237
export default stageCommand;

0 commit comments

Comments
 (0)