safedrop-cli 1.0.0

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/src/api.js ADDED
@@ -0,0 +1,170 @@
1
+ // api.js
2
+ //
3
+ // Thin client for the public SafeDrop HTTP API. Only the endpoints the CLI
4
+ // needs are implemented here. See API.md for the full contract.
5
+ //
6
+ // `baseUrl` must include any path prefix the deployment uses (the production
7
+ // web app uses ".../api"). The default points at a local dev backend.
8
+
9
+ export const DEFAULT_API_BASE = 'https://safedrop.ma/api';
10
+
11
+ export class SafeDropApiError extends Error {
12
+ constructor(statusCode, message, details) {
13
+ super(message);
14
+ this.name = 'SafeDropApiError';
15
+ this.statusCode = statusCode;
16
+ this.details = details;
17
+ }
18
+ }
19
+
20
+ /** Turn raw fetch/network failures into a human-readable message. */
21
+ function describeNetworkError(err, url) {
22
+ const code = err?.cause?.code || err?.code;
23
+ if (code === 'ECONNREFUSED') {
24
+ return `Could not connect to the SafeDrop server at ${url}. Is the URL correct and the server reachable?`;
25
+ }
26
+ if (code === 'ENOTFOUND') {
27
+ return `Could not resolve the SafeDrop server host for ${url}.`;
28
+ }
29
+ if (code === 'ETIMEDOUT' || err?.name === 'TimeoutError') {
30
+ return `The connection to ${url} timed out.`;
31
+ }
32
+ if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
33
+ return `TLS certificate problem connecting to ${url}: ${code}.`;
34
+ }
35
+ return `Network error contacting ${url}: ${err?.message || err}`;
36
+ }
37
+
38
+ export class SafeDropApi {
39
+ constructor(baseUrl = DEFAULT_API_BASE) {
40
+ this.baseUrl = String(baseUrl).replace(/\/+$/, '');
41
+ }
42
+
43
+ async #json(path, options = {}) {
44
+ const url = `${this.baseUrl}${path}`;
45
+ let res;
46
+ try {
47
+ res = await fetch(url, {
48
+ ...options,
49
+ headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
50
+ });
51
+ } catch (err) {
52
+ throw new SafeDropApiError(0, describeNetworkError(err, url));
53
+ }
54
+ const text = await res.text();
55
+ let body;
56
+ try {
57
+ body = text ? JSON.parse(text) : {};
58
+ } catch {
59
+ body = { raw: text };
60
+ }
61
+ if (!res.ok) {
62
+ const message = body?.error || body?.errors?.[0]?.msg || `Request failed (HTTP ${res.status}).`;
63
+ throw new SafeDropApiError(res.status, message, body);
64
+ }
65
+ return body;
66
+ }
67
+
68
+ /** POST /initiate-upload */
69
+ initiateUpload({ customExpirationSeconds } = {}) {
70
+ const body = {};
71
+ if (customExpirationSeconds && customExpirationSeconds > 0) {
72
+ body.customExpirationSeconds = Math.floor(customExpirationSeconds);
73
+ }
74
+ return this.#json('/initiate-upload', { method: 'POST', body: JSON.stringify(body) });
75
+ }
76
+
77
+ /** PUT to the presigned storage URL with raw encrypted bytes. */
78
+ async uploadBytes(presignedUrl, bytes) {
79
+ let res;
80
+ try {
81
+ res = await fetch(presignedUrl, {
82
+ method: 'PUT',
83
+ headers: { 'Content-Type': 'application/octet-stream' },
84
+ body: bytes,
85
+ });
86
+ } catch (err) {
87
+ throw new SafeDropApiError(0, describeNetworkError(err, 'storage'));
88
+ }
89
+ if (!res.ok) {
90
+ throw new SafeDropApiError(res.status, `Failed to upload encrypted bytes to storage (HTTP ${res.status}).`);
91
+ }
92
+ }
93
+
94
+ /** POST /upload/:uploadCode/finalize */
95
+ finalizeUpload(uploadCode, encryptedFilename, senderToken) {
96
+ return this.#json(`/upload/${uploadCode}/finalize`, {
97
+ method: 'POST',
98
+ body: JSON.stringify({ encryptedFilename, senderToken }),
99
+ });
100
+ }
101
+
102
+ /** DELETE /upload/:uploadCode (sender cancellation) */
103
+ cancelUpload(uploadCode, senderToken) {
104
+ return this.#json(`/upload/${uploadCode}`, {
105
+ method: 'DELETE',
106
+ body: JSON.stringify({ senderToken }),
107
+ });
108
+ }
109
+
110
+ /** POST /handshake/initiate */
111
+ initiateHandshake(uploadCode) {
112
+ return this.#json('/handshake/initiate', {
113
+ method: 'POST',
114
+ body: JSON.stringify({ uploadCode }),
115
+ });
116
+ }
117
+
118
+ /** POST /handshake/authorize */
119
+ authorizeHandshake(uploadCode, handshakeCode) {
120
+ return this.#json('/handshake/authorize', {
121
+ method: 'POST',
122
+ body: JSON.stringify({ uploadCode, handshakeCode }),
123
+ });
124
+ }
125
+
126
+ /**
127
+ * GET /handshake/token/:uploadCode
128
+ * Returns { downloadToken } once the sender authorizes; 404 until then.
129
+ * Throws SafeDropApiError(404) while still pending — callers poll on this.
130
+ */
131
+ getDownloadToken(uploadCode, recipientToken) {
132
+ return this.#json(`/handshake/token/${uploadCode}`, {
133
+ headers: { Authorization: `Bearer ${recipientToken}` },
134
+ });
135
+ }
136
+
137
+ /** DELETE /handshake/:uploadCode (recipient cancellation) */
138
+ cancelHandshake(uploadCode, token) {
139
+ return this.#json(`/handshake/${uploadCode}`, {
140
+ method: 'DELETE',
141
+ headers: { Authorization: `Bearer ${token}` },
142
+ });
143
+ }
144
+
145
+ /**
146
+ * GET /upload/:uploadCode (download)
147
+ * Returns { bytes: Buffer, encryptedFilename: string|null }.
148
+ * The server deletes its copy as soon as the response finishes streaming.
149
+ */
150
+ async downloadFile(uploadCode, downloadToken) {
151
+ const url = `${this.baseUrl}/upload/${uploadCode}`;
152
+ let res;
153
+ try {
154
+ res = await fetch(url, { headers: { Authorization: `Bearer ${downloadToken}` } });
155
+ } catch (err) {
156
+ throw new SafeDropApiError(0, describeNetworkError(err, url));
157
+ }
158
+ if (!res.ok) {
159
+ let message = `Download failed (HTTP ${res.status}).`;
160
+ try {
161
+ const body = await res.json();
162
+ if (body?.error) message = body.error;
163
+ } catch { /* non-JSON error body */ }
164
+ throw new SafeDropApiError(res.status, message);
165
+ }
166
+ const encryptedFilename = res.headers.get('x-encrypted-filename');
167
+ const bytes = Buffer.from(await res.arrayBuffer());
168
+ return { bytes, encryptedFilename };
169
+ }
170
+ }
package/src/cli.js ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // cli.js — argument parsing and command dispatch for `safedrop`.
3
+
4
+ import { runSend } from './send.js';
5
+ import { runReceive } from './receive.js';
6
+ import { DEFAULT_API_BASE } from './api.js';
7
+ import { log, style } from './ui.js';
8
+
9
+ const VERSION = '1.0.0';
10
+
11
+ const HELP = `${style.bold('safedrop')} — zero-knowledge encrypted file transfer from your terminal
12
+
13
+ ${style.bold('Usage:')}
14
+ safedrop send <file> [options]
15
+ safedrop receive <code-or-link> [options]
16
+
17
+ ${style.bold('Send options:')}
18
+ --ttl <minutes> How long the transfer stays available (default 15, max 1440).
19
+ --secure Enable full-security mode (out-of-band safety-code check).
20
+ --api <base-url> SafeDrop API base URL (default ${DEFAULT_API_BASE}).
21
+
22
+ ${style.bold('Receive options:')}
23
+ --output, -o <path> Where to save the file: a directory or a file path.
24
+ --api <base-url> SafeDrop API base URL (default ${DEFAULT_API_BASE}).
25
+
26
+ ${style.bold('Other:')}
27
+ --help, -h Show this help.
28
+ --version, -v Show the version.
29
+
30
+ ${style.bold('Examples:')}
31
+ safedrop send ./report.pdf
32
+ safedrop send ./report.pdf --ttl 60 --secure
33
+ safedrop send ./report.pdf --api https://safedrop.ma/api
34
+ safedrop receive eyJ1cGxvYWRDb2RlIjoi...
35
+ safedrop receive "https://safedrop.ma/#code=eyJ1cGxv..." -o ~/Downloads/
36
+ `;
37
+
38
+ /**
39
+ * Tiny flag parser. Supports "--flag value", "--flag=value", short aliases,
40
+ * and boolean flags. Unknown flags are reported.
41
+ */
42
+ function parseArgs(argv) {
43
+ const positionals = [];
44
+ const flags = {};
45
+ const aliases = { '-o': '--output', '-h': '--help', '-v': '--version' };
46
+ const booleans = new Set(['--secure', '--help', '--version']);
47
+
48
+ for (let i = 0; i < argv.length; i++) {
49
+ let arg = argv[i];
50
+ if (arg.startsWith('-')) {
51
+ let value;
52
+ const eq = arg.indexOf('=');
53
+ if (eq !== -1) { value = arg.slice(eq + 1); arg = arg.slice(0, eq); }
54
+ const name = aliases[arg] || arg;
55
+ if (booleans.has(name)) {
56
+ flags[name.slice(2)] = true;
57
+ } else {
58
+ if (value === undefined) value = argv[++i];
59
+ if (value === undefined) throw new Error(`Missing value for ${name}.`);
60
+ flags[name.slice(2)] = value;
61
+ }
62
+ } else {
63
+ positionals.push(arg);
64
+ }
65
+ }
66
+ return { positionals, flags };
67
+ }
68
+
69
+ async function main() {
70
+ let parsed;
71
+ try {
72
+ parsed = parseArgs(process.argv.slice(2));
73
+ } catch (err) {
74
+ log.error(err.message);
75
+ process.exit(1);
76
+ }
77
+ const { positionals, flags } = parsed;
78
+ const command = positionals[0];
79
+
80
+ if (flags.version) { console.log(VERSION); return; }
81
+ if (flags.help || !command) { console.log(HELP); return; }
82
+
83
+ const api = flags.api || process.env.SAFEDROP_API || DEFAULT_API_BASE;
84
+
85
+ if (command === 'send') {
86
+ const file = positionals[1];
87
+ if (!file) { log.error('Usage: safedrop send <file> [--ttl <minutes>] [--secure] [--api <url>]'); process.exit(1); }
88
+
89
+ let ttlMinutes;
90
+ if (flags.ttl !== undefined) {
91
+ ttlMinutes = Number(flags.ttl);
92
+ if (!Number.isFinite(ttlMinutes) || ttlMinutes <= 0) { log.error('--ttl must be a positive number of minutes.'); process.exit(1); }
93
+ if (ttlMinutes > 1440) { log.error('--ttl cannot exceed 1440 minutes (24 hours).'); process.exit(1); }
94
+ }
95
+ await runSend(file, { api, ttlMinutes, fullSecurity: !!flags.secure });
96
+ return;
97
+ }
98
+
99
+ if (command === 'receive') {
100
+ const codeOrLink = positionals[1];
101
+ if (!codeOrLink) { log.error('Usage: safedrop receive <code-or-link> [--output <path>] [--api <url>]'); process.exit(1); }
102
+ await runReceive(codeOrLink, { api, output: flags.output });
103
+ return;
104
+ }
105
+
106
+ log.error(`Unknown command: ${command}`);
107
+ console.log(`\n${HELP}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ main().catch((err) => {
112
+ log.error(err?.message || String(err));
113
+ process.exit(1);
114
+ });
package/src/code.js ADDED
@@ -0,0 +1,104 @@
1
+ // code.js
2
+ //
3
+ // Combined-code and share-link handling. Byte-for-byte compatible with the
4
+ // browser client:
5
+ //
6
+ // combinedCode = base64( JSON.stringify({ uploadCode, key, fullSecurity }) )
7
+ // shareLink = `${origin}/#code=${combinedCode}`
8
+ //
9
+ // The browser puts the code in the URL *fragment* (#...), never the query
10
+ // string, so the secret key is never sent to any server during navigation.
11
+
12
+ const UPLOAD_CODE_LENGTH = 16;
13
+
14
+ /**
15
+ * Build the combined share code from its parts.
16
+ * @returns {string} base64 JSON code
17
+ */
18
+ export function encodeCombinedCode({ uploadCode, key, fullSecurity = false }) {
19
+ if (!uploadCode || !key) {
20
+ throw new Error('uploadCode and key are required to build a share code.');
21
+ }
22
+ const json = JSON.stringify({ uploadCode, key, fullSecurity: !!fullSecurity });
23
+ return Buffer.from(json, 'utf8').toString('base64');
24
+ }
25
+
26
+ /**
27
+ * Build the browser share link, with the code in the URL fragment.
28
+ * @param {string} combinedCode
29
+ * @param {string} origin e.g. "https://safedrop.ma"
30
+ */
31
+ export function encodeShareLink(combinedCode, origin) {
32
+ const base = String(origin || '').replace(/\/+$/, '');
33
+ return `${base}/#code=${combinedCode}`;
34
+ }
35
+
36
+ /**
37
+ * Extract the raw combined code from arbitrary user input: a bare combined
38
+ * code, a full SafeDrop link (fragment or query form), or a code with
39
+ * surrounding whitespace.
40
+ *
41
+ * Mirrors the browser's hash/query handling:
42
+ * new URLSearchParams(hash).get('code') || query.get('code')
43
+ *
44
+ * @param {string} input
45
+ * @returns {string} the bare combined (base64) code
46
+ */
47
+ export function extractCombinedCode(input) {
48
+ if (typeof input !== 'string' || !input.trim()) {
49
+ throw new Error('No code or link provided.');
50
+ }
51
+ const raw = input.trim();
52
+
53
+ // Looks like a URL? Pull `code` from the fragment first, then the query.
54
+ if (/^https?:\/\//i.test(raw) || raw.includes('#code=') || raw.includes('?code=')) {
55
+ let url;
56
+ try {
57
+ url = new URL(raw);
58
+ } catch {
59
+ // Not a parseable URL but may contain a #code= / ?code= chunk.
60
+ const m = raw.match(/[#?&]code=([^&\s]+)/);
61
+ if (m) return decodeURIComponent(m[1]);
62
+ throw new Error('Could not parse the SafeDrop link.');
63
+ }
64
+ const fragment = url.hash.replace(/^#/, '');
65
+ const fromFragment = new URLSearchParams(fragment).get('code');
66
+ const fromQuery = url.searchParams.get('code');
67
+ const code = fromFragment || fromQuery;
68
+ if (!code) throw new Error('Link does not contain a SafeDrop code.');
69
+ return code;
70
+ }
71
+
72
+ // Otherwise treat the whole thing as a bare combined code.
73
+ return raw;
74
+ }
75
+
76
+ /**
77
+ * Decode a combined code (or link) into its parts.
78
+ * @param {string} input bare code or full link
79
+ * @returns {{ uploadCode: string, key: string, fullSecurity: boolean }}
80
+ */
81
+ export function decodeCombinedCode(input) {
82
+ const combined = extractCombinedCode(input);
83
+
84
+ let data;
85
+ try {
86
+ const json = Buffer.from(combined, 'base64').toString('utf8');
87
+ data = JSON.parse(json);
88
+ } catch {
89
+ throw new Error('Invalid SafeDrop code: could not decode.');
90
+ }
91
+
92
+ const { uploadCode, key } = data;
93
+ // Browser defaults fullSecurity to true when the field is absent.
94
+ const fullSecurity = data.fullSecurity ?? true;
95
+
96
+ if (typeof uploadCode !== 'string' || uploadCode.length !== UPLOAD_CODE_LENGTH) {
97
+ throw new Error('Invalid SafeDrop code: bad upload code.');
98
+ }
99
+ if (typeof key !== 'string' || !/^[0-9a-fA-F]{64}$/.test(key)) {
100
+ throw new Error('Invalid SafeDrop code: bad encryption key.');
101
+ }
102
+
103
+ return { uploadCode, key, fullSecurity: !!fullSecurity };
104
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,82 @@
1
+ // crypto.js
2
+ //
3
+ // SafeDrop encryption primitives — byte-for-byte compatible with the browser
4
+ // client (frontend/utils/crypto.ts), which uses the Web Crypto API.
5
+ //
6
+ // Wire format (identical in both directions):
7
+ //
8
+ // payload = IV (12 bytes) || ciphertext || GCM auth tag (16 bytes)
9
+ //
10
+ // Web Crypto's `encrypt()` appends the 16-byte auth tag to the ciphertext, so
11
+ // the browser produces exactly this layout. Node's crypto exposes the tag
12
+ // separately via getAuthTag(), so we concatenate it to match.
13
+ //
14
+ // Keys are 256-bit, represented as a 64-character lowercase hex string.
15
+ //
16
+ // IMPORTANT: strings (e.g. filenames) are encoded as UTF-16LE, NOT UTF-8.
17
+ // The browser's str2ab() writes charCodeAt() values into a Uint16Array, which
18
+ // is little-endian on all common platforms. We must match that exactly or
19
+ // filenames will not round-trip across browser <-> CLI.
20
+
21
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
22
+
23
+ const IV_LENGTH = 12;
24
+ const TAG_LENGTH = 16;
25
+ const ALGORITHM = 'aes-256-gcm';
26
+
27
+ /** Generate a fresh AES-256 key, returned as a 64-char hex string. */
28
+ export function generateKeyHex() {
29
+ return randomBytes(32).toString('hex');
30
+ }
31
+
32
+ function keyFromHex(keyHex) {
33
+ if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
34
+ throw new Error('Invalid encryption key (expected 64 hex characters).');
35
+ }
36
+ return Buffer.from(keyHex, 'hex');
37
+ }
38
+
39
+ /**
40
+ * Encrypt a binary buffer.
41
+ * Returns { payload: Buffer, keyHex: string }. If keyHex is omitted, a new key
42
+ * is generated (matching the browser's encryptBuffer behaviour).
43
+ */
44
+ export function encryptBuffer(plain, keyHex = generateKeyHex()) {
45
+ const key = keyFromHex(keyHex);
46
+ const iv = randomBytes(IV_LENGTH);
47
+ const cipher = createCipheriv(ALGORITHM, key, iv);
48
+ const enc = Buffer.concat([cipher.update(plain), cipher.final()]);
49
+ const tag = cipher.getAuthTag();
50
+ return { payload: Buffer.concat([iv, enc, tag]), keyHex };
51
+ }
52
+
53
+ /** Decrypt a binary payload (IV || ciphertext || tag) back to a Buffer. */
54
+ export function decryptBuffer(payload, keyHex) {
55
+ const key = keyFromHex(keyHex);
56
+ const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
57
+ if (data.length < IV_LENGTH + TAG_LENGTH) {
58
+ throw new Error('Ciphertext is too short to be valid.');
59
+ }
60
+ const iv = data.subarray(0, IV_LENGTH);
61
+ const tag = data.subarray(data.length - TAG_LENGTH);
62
+ const ct = data.subarray(IV_LENGTH, data.length - TAG_LENGTH);
63
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
64
+ decipher.setAuthTag(tag);
65
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
66
+ }
67
+
68
+ /**
69
+ * Encrypt a UTF-16LE string, returning base64 (matches browser encryptString).
70
+ * Used for filenames.
71
+ */
72
+ export function encryptString(plainText, keyHex) {
73
+ const ptBuf = Buffer.from(String(plainText), 'utf16le');
74
+ const { payload } = encryptBuffer(ptBuf, keyHex);
75
+ return payload.toString('base64');
76
+ }
77
+
78
+ /** Decrypt a base64 string payload back to a UTF-16LE string. */
79
+ export function decryptString(base64Payload, keyHex) {
80
+ const payload = Buffer.from(base64Payload, 'base64');
81
+ return decryptBuffer(payload, keyHex).toString('utf16le');
82
+ }
package/src/index.js ADDED
@@ -0,0 +1,25 @@
1
+ // index.js — library entry point.
2
+ //
3
+ // Exposes the auditable building blocks so the CLI's behaviour can be reused or
4
+ // tested programmatically. No secrets are ever logged by these functions.
5
+
6
+ export {
7
+ generateKeyHex,
8
+ encryptBuffer,
9
+ decryptBuffer,
10
+ encryptString,
11
+ decryptString,
12
+ } from './crypto.js';
13
+
14
+ export {
15
+ encodeCombinedCode,
16
+ encodeShareLink,
17
+ extractCombinedCode,
18
+ decodeCombinedCode,
19
+ } from './code.js';
20
+
21
+ export { generateSAS } from './sas.js';
22
+ export { sanitizeFilename, resolveOutputPath, dedupePath } from './paths.js';
23
+ export { SafeDropApi, SafeDropApiError, DEFAULT_API_BASE } from './api.js';
24
+ export { runSend } from './send.js';
25
+ export { runReceive } from './receive.js';
package/src/paths.js ADDED
@@ -0,0 +1,95 @@
1
+ // paths.js
2
+ //
3
+ // Safe handling of the decrypted output path. The filename comes from an
4
+ // encrypted blob the *sender* controls, so after decryption it is untrusted
5
+ // input. We must:
6
+ // - strip any directory components from the sender-supplied name
7
+ // - refuse names that try to escape the chosen output directory
8
+ // - never silently overwrite an existing local file
9
+
10
+ import path from 'node:path';
11
+
12
+ /**
13
+ * Reduce a sender-supplied filename to a safe basename.
14
+ * Removes directory separators, drive letters, leading dots-only names, and
15
+ * control characters. Falls back to a default when nothing usable remains.
16
+ */
17
+ export function sanitizeFilename(name, fallback = 'safedrop-file') {
18
+ if (typeof name !== 'string') return fallback;
19
+
20
+ // Take the last path segment under both POSIX and Windows separators.
21
+ let base = name.split(/[\\/]/).pop() || '';
22
+
23
+ // Drop NUL and control characters, and Windows-illegal characters.
24
+ // eslint-disable-next-line no-control-regex
25
+ base = base.replace(/[\x00-\x1f<>:"|?*]/g, '').trim();
26
+
27
+ // Reject pure-dot names (".", "..") and empties.
28
+ if (!base || /^\.+$/.test(base)) return fallback;
29
+
30
+ // Avoid Windows reserved device names (CON, PRN, NUL, COM1, ...).
31
+ const stem = base.replace(/\.[^.]*$/, '');
32
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) {
33
+ return `_${base}`;
34
+ }
35
+ return base;
36
+ }
37
+
38
+ /**
39
+ * Resolve the final absolute output path from the user's --output option and
40
+ * the (sanitized) sender filename, then verify it does not escape its parent
41
+ * directory.
42
+ *
43
+ * Rules:
44
+ * - no --output -> sanitized filename in the current working directory
45
+ * - --output dir/ (or existing directory) -> sanitized filename inside it
46
+ * - --output file -> that exact path (its basename is still sanitized)
47
+ *
48
+ * @returns {string} absolute, validated output path
49
+ */
50
+ export function resolveOutputPath(senderFilename, outputOption, { cwd = process.cwd(), isDirectory } = {}) {
51
+ const safeName = sanitizeFilename(senderFilename);
52
+
53
+ let target;
54
+ if (!outputOption) {
55
+ target = path.resolve(cwd, safeName);
56
+ } else {
57
+ const resolvedOpt = path.resolve(cwd, outputOption);
58
+ // A trailing separator is an explicit "this is a directory" intent and wins
59
+ // even if the directory does not exist yet. Otherwise probe the filesystem.
60
+ const endsWithSep = /[\\/]$/.test(outputOption);
61
+ const looksLikeDir =
62
+ endsWithSep || (typeof isDirectory === 'function' && isDirectory(resolvedOpt));
63
+ if (looksLikeDir) {
64
+ target = path.join(resolvedOpt, safeName);
65
+ } else {
66
+ // Treat as a file path, but sanitize the basename the user gave.
67
+ target = path.join(path.dirname(resolvedOpt), sanitizeFilename(path.basename(resolvedOpt)));
68
+ }
69
+ }
70
+
71
+ // Containment check: the final basename must stay within its parent dir.
72
+ const parent = path.dirname(target);
73
+ const rel = path.relative(parent, target);
74
+ if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(path.sep)) {
75
+ throw new Error(`Refusing to write outside the target directory: ${senderFilename}`);
76
+ }
77
+
78
+ return target;
79
+ }
80
+
81
+ /**
82
+ * Pick a non-clobbering path by appending " (1)", " (2)", ... before the
83
+ * extension. Used when the user declines to overwrite.
84
+ */
85
+ export function dedupePath(target, exists) {
86
+ if (!exists(target)) return target;
87
+ const dir = path.dirname(target);
88
+ const ext = path.extname(target);
89
+ const stem = path.basename(target, ext);
90
+ for (let i = 1; i < 10000; i++) {
91
+ const candidate = path.join(dir, `${stem} (${i})${ext}`);
92
+ if (!exists(candidate)) return candidate;
93
+ }
94
+ throw new Error('Could not find an available filename.');
95
+ }