sync: update from private repo (2ef0fa6)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-17 05:23:28 +00:00
parent 517142c61d
commit 1602d52510
42 changed files with 3387 additions and 64 deletions
+35 -2
View File
@@ -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
View File
@@ -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 };
}