This commit is contained in:
@@ -105,10 +105,43 @@ describe('ssh/path-policy validateRemotePath', () => {
|
||||
expect(validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses repeated backslashes in Windows path', () => {
|
||||
it('collapses repeated backslashes and emits forward-slash form', () => {
|
||||
const r = validateRemotePath('C:\\Users\\\\agent\\\\file', 'C:\\Users\\agent');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized).toBe('C:\\Users\\agent\\file');
|
||||
// SFTP wire form: backslashes → forward slashes, drive path gets leading '/'.
|
||||
expect(r.normalized).toBe('/C:/Users/agent/file');
|
||||
});
|
||||
|
||||
it('normalizes Windows drive paths to /C:/... (leading slash) for SFTP', () => {
|
||||
expect(validateRemotePath('C:\\Users\\agent\\f', 'C:\\Users\\agent').normalized).toBe(
|
||||
'/C:/Users/agent/f',
|
||||
);
|
||||
// Already-canonical leading-slash form is idempotent.
|
||||
expect(validateRemotePath('/C:/Users/agent/f', '/C:/Users/agent').normalized).toBe(
|
||||
'/C:/Users/agent/f',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts forward-slash Windows drive prefix (natural Win OpenSSH config)', () => {
|
||||
// Regression: detectSeparator used to flag any drive path as backslash-style,
|
||||
// so a forward-slash prefix + forward-slash path was wrongly rejected.
|
||||
expect(validateRemotePath('C:/Users/agent', 'C:/Users/agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:/Users/agent/file.txt', 'C:/Users/agent').normalized).toBe(
|
||||
'/C:/Users/agent/file.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts mixed separators between prefix and candidate', () => {
|
||||
// Backslash prefix + forward-slash path (and vice versa) must line up.
|
||||
expect(validateRemotePath('C:/Users/agent/file', 'C:\\Users\\agent').ok).toBe(true);
|
||||
expect(validateRemotePath('C:\\Users\\agent\\file', 'C:/Users/agent').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('emits forward-slash UNC form', () => {
|
||||
const r = validateRemotePath('\\\\srv\\share\\agent\\file', '\\\\srv\\share\\agent');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized).toBe('//srv/share/agent/file');
|
||||
});
|
||||
|
||||
it('rejects empty prefix candidate via outside_prefix when prefix is non-trivial', () => {
|
||||
|
||||
+38
-33
@@ -48,24 +48,42 @@ export interface LocalPathResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary path separator used in a prefix string.
|
||||
* Windows-style: drive letter (`C:\`), UNC (`\\server\share`), or backslashes
|
||||
* without forward slashes. POSIX-style: everything else.
|
||||
* Canonicalise a remote path to forward-slash form for comparison AND for the
|
||||
* wire.
|
||||
*
|
||||
* SFTP uses '/' as its separator on every platform — Windows OpenSSH's
|
||||
* sftp-server canonicalises drive paths to '/C:/Users/...' (leading slash,
|
||||
* forward slashes). Comparing in '/'-space lets a prefix or candidate written
|
||||
* with backslashes, forward slashes, or a mix all line up, and guarantees the
|
||||
* path we ultimately hand to `sftp.createWriteStream` / `sftp.stat` is in the
|
||||
* one form every server accepts.
|
||||
*
|
||||
* 'C:\\Users\\agent' → '/C:/Users/agent'
|
||||
* 'C:/Users/agent' → '/C:/Users/agent'
|
||||
* '/C:/Users/agent' → '/C:/Users/agent' (idempotent)
|
||||
* '\\\\srv\\share\\agent' → '//srv/share/agent' (UNC head preserved)
|
||||
* '/home/u' → '/home/u' (POSIX unchanged)
|
||||
*
|
||||
* Caller has already rejected `..` segments, so posix.normalize cannot escape.
|
||||
*/
|
||||
function detectSeparator(p: string): '/' | '\\' {
|
||||
if (/^[a-zA-Z]:[\\/]/.test(p)) return '\\';
|
||||
if (p.startsWith('\\\\')) return '\\';
|
||||
if (p.includes('\\') && !p.includes('/')) return '\\';
|
||||
return '/';
|
||||
function toForwardSlash(s: string): string {
|
||||
// Preserve a UNC double-leading separator ('\\\\server' / '//server') as '//'.
|
||||
const uncHead = /^[\\/]{2}/.test(s) ? '//' : '';
|
||||
const body = path.posix.normalize(s.slice(uncHead.length).replace(/\\/g, '/'));
|
||||
let out = uncHead + body;
|
||||
// A bare drive path ('C:/...') gets the leading slash Windows OpenSSH expects.
|
||||
if (/^[A-Za-z]:\//.test(out)) out = '/' + out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a candidate REMOTE path against the per-connection prefix.
|
||||
*
|
||||
* Pure string operations — no FS I/O (remote FS isn't ours to stat).
|
||||
* Supports both POSIX (`/`) and Windows (`\`) path styles. The prefix's
|
||||
* primary separator is used to compare; mixing styles between prefix and
|
||||
* candidate path will trip the segment-boundary check.
|
||||
* Separator-agnostic: prefix and candidate are canonicalised to forward-slash
|
||||
* form (toForwardSlash) before comparing, so '/', '\', or a mix all line up.
|
||||
* The `normalized` result is the '/'-delimited form handed to SFTP — Windows
|
||||
* drive paths come back as '/C:/Users/...', which Windows OpenSSH accepts.
|
||||
*
|
||||
* prefix = '/home/u'
|
||||
* '/home/u' → ok
|
||||
@@ -78,8 +96,9 @@ function detectSeparator(p: string): '/' | '\\' {
|
||||
* '' → empty
|
||||
* '/foo\x00bar' → has_nul
|
||||
*
|
||||
* prefix = 'C:\\Users\\agent'
|
||||
* 'C:\\Users\\agent\\file' → ok
|
||||
* prefix = 'C:\\Users\\agent' (or 'C:/Users/agent' — same result)
|
||||
* 'C:\\Users\\agent\\file' → ok (normalized '/C:/Users/agent/file')
|
||||
* 'C:/Users/agent/file' → ok (normalized '/C:/Users/agent/file')
|
||||
* 'C:\\Users\\agent2\\f' → outside_prefix
|
||||
* 'C:\\..\\Windows\\sys' → has_parent_ref
|
||||
*
|
||||
@@ -101,32 +120,18 @@ export function validateRemotePath(remotePath: string, prefix: string): RemotePa
|
||||
return { ok: false, reason: 'has_parent_ref' };
|
||||
}
|
||||
|
||||
const sep = detectSeparator(prefix);
|
||||
// POSIX paths normalize via path.posix (collapses '//' and '/./').
|
||||
// Windows paths get a lightweight collapse of repeated backslashes only;
|
||||
// posix.normalize would corrupt drive letters or UNC heads.
|
||||
const normalize = (s: string): string => {
|
||||
if (sep === '/') return path.posix.normalize(s);
|
||||
// Preserve leading '\\' (UNC) by capturing it before collapsing.
|
||||
const uncHead = s.startsWith('\\\\') ? '\\\\' : '';
|
||||
const body = s.slice(uncHead.length).replace(/\\{2,}/g, '\\');
|
||||
return uncHead + body;
|
||||
};
|
||||
|
||||
// Compare in forward-slash space (see toForwardSlash). The normalized result
|
||||
// is what gets handed to SFTP, so it is always '/'-delimited.
|
||||
const stripTrailingSep = (s: string): string =>
|
||||
s.length > 1 && (s.endsWith('/') || s.endsWith('\\')) ? s.slice(0, -1) : s;
|
||||
s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s;
|
||||
|
||||
const normalized = normalize(remotePath);
|
||||
const normalizedTrimmed = stripTrailingSep(normalized);
|
||||
const prefixTrimmed = stripTrailingSep(prefix);
|
||||
const normalizedTrimmed = stripTrailingSep(toForwardSlash(remotePath));
|
||||
const prefixTrimmed = stripTrailingSep(toForwardSlash(prefix));
|
||||
|
||||
if (normalizedTrimmed === prefixTrimmed) {
|
||||
return { ok: true, normalized: normalizedTrimmed };
|
||||
}
|
||||
const prefixWithSep =
|
||||
prefixTrimmed === '/' || prefixTrimmed === '\\'
|
||||
? prefixTrimmed
|
||||
: `${prefixTrimmed}${sep}`;
|
||||
const prefixWithSep = prefixTrimmed === '/' ? '/' : `${prefixTrimmed}/`;
|
||||
if (normalizedTrimmed.startsWith(prefixWithSep)) {
|
||||
return { ok: true, normalized: normalizedTrimmed };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user