diff --git a/lib/index.ts b/lib/index.ts index 336b1ef..496e871 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -131,3 +131,36 @@ export function isSamePath(path1: string, path2: string): boolean { return path1 === path2 } + +/** + * Normalizes the given path by removing leading, trailing and doubled slashes and also removing the dot sections. + * + * @param path - The path to normalize + */ +export function normalize(path: string): string { + const sections = path.split('/') + .filter((p, index, arr) => p !== '' || index === 0 || index === (arr.length - 1)) // remove double // but keep leading and trailing slash + .filter((p) => p !== '.') // remove useless /./ sections + + const sanitizedSections: string[] = [] + for (const section of sections) { + const lastSection = sanitizedSections.at(-1) + if (section === '..' && lastSection !== '..') { + // if the current section is ".." for the parent, we remove the last section + // But only if the last section is not also ".." which means that we are in a relative path outside of the root + // Note that absolute paths like "/../../foo" are valid as they resolve to "/foo" + if (lastSection === undefined) { + // if there is no last section, we are at the root and we can't go up further + // so we keep the ".." section as this is a relative path outside of the root + sanitizedSections.push(section) + } else if (lastSection !== '') { + // only remove parent if its not the root (leading slash) + sanitizedSections.pop() + } + } else { + sanitizedSections.push(section) + } + } + + return sanitizedSections.join('/') +} diff --git a/test/normalize.test.ts b/test/normalize.test.ts new file mode 100644 index 0000000..4c7a31d --- /dev/null +++ b/test/normalize.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { expect, test } from 'vitest' +import { normalize } from '../lib/index.ts' + +test('normalize', () => { + expect(normalize('./fixtures///b/../b/c.js')).toBe('fixtures/b/c.js') + expect(normalize('/foo/../../../bar')).toBe('/bar') + expect(normalize('a//b//../b')).toBe('a/b') + expect(normalize('a//b//./c')).toBe('a/b/c') + expect(normalize('a//b//.')).toBe('a/b') + expect(normalize('/a/b/c/../../../x/y/z')).toBe('/x/y/z') + expect(normalize('///..//./foo/.//bar')).toBe('/foo/bar') + expect(normalize('bar/foo../../')).toBe('bar/') + expect(normalize('bar/foo../..')).toBe('bar') + expect(normalize('bar/foo../../baz')).toBe('bar/baz') + expect(normalize('bar/foo../')).toBe('bar/foo../') + expect(normalize('bar/foo..')).toBe('bar/foo..') + expect(normalize('../foo../../../bar')).toBe('../../bar') + expect(normalize('../../.././../../../bar')).toBe('../../../../../../bar') + expect(normalize('../../../foo/../../../bar')).toBe('../../../../../bar') + expect(normalize('../../../foo/../../../bar/../../')).toBe('../../../../../../') + expect(normalize('../foobar/barfoo/foo/../../../bar/../../')).toBe('../../') + expect(normalize('../../../foobar/../../../bar/../../baz')).toBe('../../../../../../baz') + expect(normalize('/../../../foobar/../../../bar/../../baz')).toBe('/baz') +})