From acb53f18be49c91bb5db947a97b0ecc56f26e173 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Sat, 2 May 2026 12:46:03 +0100 Subject: [PATCH] Inline and harden tilde path expansion --- lib/plugins/create/create.js | 2 +- lib/utils/create-from-local-template.js | 2 +- lib/utils/download-template-from-repo.js | 2 +- lib/utils/untildify.js | 75 +++++++ package.json | 1 - test/unit/lib/plugins/create/create.test.js | 94 ++++++++- .../utils/create-from-local-template.test.js | 67 ++++++ .../utils/download-template-from-repo.test.js | 103 ++++++++++ test/unit/lib/utils/untildify.test.js | 191 ++++++++++++++++++ 9 files changed, 530 insertions(+), 7 deletions(-) create mode 100644 lib/utils/untildify.js create mode 100644 test/unit/lib/utils/untildify.test.js diff --git a/lib/plugins/create/create.js b/lib/plugins/create/create.js index 1ac63a4a3..31ef2e632 100644 --- a/lib/plugins/create/create.js +++ b/lib/plugins/create/create.js @@ -1,7 +1,7 @@ 'use strict'; const path = require('path'); -const untildify = require('untildify'); +const untildify = require('../../utils/untildify'); const ServerlessError = require('../../serverless-error'); const cliCommandsSchema = require('../../cli/commands-schema'); diff --git a/lib/utils/create-from-local-template.js b/lib/utils/create-from-local-template.js index 74b863564..a71d75aae 100644 --- a/lib/utils/create-from-local-template.js +++ b/lib/utils/create-from-local-template.js @@ -1,6 +1,6 @@ 'use strict'; -const untildify = require('untildify'); +const untildify = require('./untildify'); const fsp = require('fs').promises; const copy = require('./fs/copy'); const { renameService } = require('./rename-service'); diff --git a/lib/utils/download-template-from-repo.js b/lib/utils/download-template-from-repo.js index 562cc27e3..fafc546fd 100644 --- a/lib/utils/download-template-from-repo.js +++ b/lib/utils/download-template-from-repo.js @@ -7,7 +7,7 @@ const URL = require('url'); const download = require('./serverless-utils/download'); const qs = require('querystring'); const spawn = require('./spawn'); -const untildify = require('untildify'); +const untildify = require('./untildify'); const renameService = require('./rename-service').renameService; const ServerlessError = require('../serverless-error'); const copyDirContentsSync = require('./fs/copy-dir-contents-sync'); diff --git a/lib/utils/untildify.js b/lib/utils/untildify.js new file mode 100644 index 000000000..ab3587703 --- /dev/null +++ b/lib/utils/untildify.js @@ -0,0 +1,75 @@ +'use strict'; + +const os = require('os'); +const ServerlessError = require('../serverless-error'); + +let homeDirectory; +let currentUser; + +const getHomeDirectory = (inputPath) => { + if (homeDirectory === undefined) { + try { + homeDirectory = os.homedir() || null; + } catch (error) { + throw new ServerlessError( + `Cannot expand path "${inputPath}": home directory could not be resolved: ${error.message}`, + 'HOME_DIRECTORY_UNAVAILABLE' + ); + } + } + + if (homeDirectory) return homeDirectory; + + throw new ServerlessError( + `Cannot expand path "${inputPath}": home directory could not be resolved.`, + 'HOME_DIRECTORY_UNAVAILABLE' + ); +}; + +const getCurrentUser = (inputPath) => { + if (currentUser === undefined) { + try { + const userInfo = os.userInfo(); + currentUser = userInfo && userInfo.username ? userInfo.username : null; + } catch (error) { + throw new ServerlessError( + `Cannot expand path "${inputPath}": current user could not be resolved: ${error.message}`, + 'CURRENT_USER_UNAVAILABLE' + ); + } + } + + if (currentUser) return currentUser; + + throw new ServerlessError( + `Cannot expand path "${inputPath}": current user could not be resolved.`, + 'CURRENT_USER_UNAVAILABLE' + ); +}; + +module.exports = (pathWithTilde) => { + if (typeof pathWithTilde !== 'string') { + throw new TypeError(`Expected a string, got ${typeof pathWithTilde}`); + } + + if (!pathWithTilde.startsWith('~')) { + return pathWithTilde; + } + + if (/^~(?=$|\/|\\)/.test(pathWithTilde)) { + return `${getHomeDirectory(pathWithTilde)}${pathWithTilde.slice(1)}`; + } + + const userMatch = pathWithTilde.match(/^~([^/\\]+)(.*)/); + const username = userMatch[1]; + const rest = userMatch[2]; + + if (username !== getCurrentUser(pathWithTilde)) { + throw new ServerlessError( + `Cannot expand path "${pathWithTilde}": user-home expansion is only supported for the current user.`, + 'UNSUPPORTED_HOME_DIRECTORY_EXPANSION' + ); + } + + return `${getHomeDirectory(pathWithTilde)}${rest}`; +}; diff --git a/package.json b/package.json index 5cea8ac3c..fcbb4b36b 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "tsx": "^4.20.3", "type": "^2.7.2", "undici": "^7.25.0", - "untildify": "^4.0.0", "write-file-atomic": "^7.0.1", "yauzl": "^3.3.0", "yazl": "^3.3.1" diff --git a/test/unit/lib/plugins/create/create.test.js b/test/unit/lib/plugins/create/create.test.js index 97bf2e36d..f57d72323 100644 --- a/test/unit/lib/plugins/create/create.test.js +++ b/test/unit/lib/plugins/create/create.test.js @@ -2,6 +2,7 @@ const http = require('http'); const fsp = require('fs').promises; +const os = require('os'); const path = require('path'); const AdmZip = require('adm-zip'); const proxyquire = require('proxyquire'); @@ -14,13 +15,13 @@ const { expect } = require('chai'); const fixturesPath = path.resolve(__dirname, '../../../../fixtures/programmatic'); -const loadCreate = ({ downloadTemplateFromRepoStub, dirExistsSyncStub } = {}) => { +const loadCreate = ({ downloadTemplateFromRepoStub, dirExistsSyncStub, untildifyStub } = {}) => { const noticeStub = sinon.stub(); noticeStub.success = sinon.stub(); const copyDirContentsSyncStub = sinon.stub(); const renameServiceStub = sinon.stub(); - const Create = proxyquire.noCallThru().load('../../../../../lib/plugins/create/create', { + const stubs = { '../../utils/download-template-from-repo': { downloadTemplateFromRepo: downloadTemplateFromRepoStub || sinon.stub(), }, @@ -40,7 +41,10 @@ const loadCreate = ({ downloadTemplateFromRepoStub, dirExistsSyncStub } = {}) => aside: () => '', }, }, - }); + }; + if (untildifyStub) stubs['../../utils/untildify'] = untildifyStub; + + const Create = proxyquire.noCallThru().load('../../../../../lib/plugins/create/create', stubs); return { Create, @@ -294,6 +298,62 @@ describe('test/unit/lib/plugins/create/create.test.js', () => { ); }); + it('should expand a home-relative local template path before copying', async () => { + const { Create, copyDirContentsSyncStub, noticeSuccessStub, renameServiceStub } = + loadCreate(); + + await new Create( + { + pluginManager: { + commandRunStartTime: Date.now(), + }, + }, + { + 'template-path': '~/aws', + } + ).create(); + + expect(copyDirContentsSyncStub.calledOnce).to.equal(true); + expect(path.normalize(copyDirContentsSyncStub.firstCall.args[0])).to.equal( + path.join(os.homedir(), 'aws') + ); + expect(copyDirContentsSyncStub.firstCall.args[1]).to.equal(path.join(process.cwd(), 'aws')); + expect(renameServiceStub.called).to.equal(false); + expect(noticeSuccessStub.firstCall.args[0]).to.contain( + 'Project successfully created in "./aws"' + ); + }); + + it('should stop before copying when local template path expansion fails', async () => { + const expansionError = new ServerlessError( + 'Cannot expand path "~/aws": home directory could not be resolved.', + 'HOME_DIRECTORY_UNAVAILABLE' + ); + const { Create, copyDirContentsSyncStub, renameServiceStub } = loadCreate({ + untildifyStub: sinon.stub().throws(expansionError), + }); + + try { + await new Create( + { + pluginManager: { + commandRunStartTime: Date.now(), + }, + }, + { + 'template-path': '~/aws', + } + ).create(); + } catch (error) { + expect(error).to.equal(expansionError); + expect(copyDirContentsSyncStub).to.not.have.been.called; + expect(renameServiceStub).to.not.have.been.called; + return; + } + + throw new Error('Expected create() to reject'); + }); + it('should default the service name to the target directory basename when only --path is provided', async () => { const { Create, copyDirContentsSyncStub, noticeSuccessStub, renameServiceStub } = loadCreate(); @@ -324,6 +384,34 @@ describe('test/unit/lib/plugins/create/create.test.js', () => { ); }); + it('should expand a home-relative local target path while reporting the original path', async () => { + const { Create, copyDirContentsSyncStub, noticeSuccessStub, renameServiceStub } = + loadCreate(); + const targetPath = '~/nested/service-directory'; + const expectedServiceDir = path.join(os.homedir(), 'nested', 'service-directory'); + + await new Create( + { + pluginManager: { + commandRunStartTime: Date.now(), + }, + }, + { + 'template-path': path.join(fixturesPath, 'aws'), + 'path': targetPath, + } + ).create(); + + expect(copyDirContentsSyncStub.calledOnce).to.equal(true); + expect(copyDirContentsSyncStub.firstCall.args[1]).to.equal(expectedServiceDir); + expect( + renameServiceStub.calledOnceWithExactly('service-directory', expectedServiceDir) + ).to.equal(true); + expect(noticeSuccessStub.firstCall.args[0]).to.contain( + 'Project successfully created in "~/nested/service-directory"' + ); + }); + it('should report the provided local target path when the directory already exists', async () => { const { Create } = loadCreate({ dirExistsSyncStub: sinon.stub().returns(true), diff --git a/test/unit/lib/utils/create-from-local-template.test.js b/test/unit/lib/utils/create-from-local-template.test.js index eae7fc664..8830e3224 100644 --- a/test/unit/lib/utils/create-from-local-template.test.js +++ b/test/unit/lib/utils/create-from-local-template.test.js @@ -4,8 +4,11 @@ const path = require('path'); const chai = require('chai'); const fs = require('fs'); const fsp = require('fs').promises; +const proxyquire = require('proxyquire').noCallThru().noPreserveCache(); +const sinon = require('sinon'); const { load: yamlParse } = require('js-yaml'); const createFromLocalTemplate = require('../../../../lib/utils/create-from-local-template'); +const ServerlessError = require('../../../../lib/serverless-error'); const { ensureDir, getTmpDirPath, outputFile, pathExists, remove } = require('../../../utils/fs'); const skipOnDisabledSymlinksInWindows = require('../../../lib/skip-on-disabled-symlinks-in-windows'); @@ -13,6 +16,14 @@ const fixturesPath = path.resolve(__dirname, '../../../fixtures/programmatic'); const expect = chai.expect; +const loadUntildifyWithHome = (homeDirectory) => + proxyquire('../../../../lib/utils/untildify', { + os: { + homedir: () => homeDirectory, + userInfo: () => ({ username: 'test-user' }), + }, + }); + describe('test/unit/lib/utils/create-from-local-template.test.js', () => { describe('Without `projectName` provided', () => { it('should create from template referenced locally', async () => { @@ -25,6 +36,62 @@ describe('test/unit/lib/utils/create-from-local-template.test.js', () => { expect(stats.isFile()).to.be.true; }); + it('should expand a home-relative template path', async () => { + const fakeHome = getTmpDirPath(); + const templateDirName = 'test-template'; + const homeTemplatePath = path.join(fakeHome, templateDirName); + const projectDir = path.join(getTmpDirPath(), 'some-service'); + const createFromLocalTemplateWithFakeHome = proxyquire( + '../../../../lib/utils/create-from-local-template', + { + './untildify': loadUntildifyWithHome(fakeHome), + } + ); + + try { + await outputFile(path.join(homeTemplatePath, 'serverless.yml'), 'service: source\n'); + + await createFromLocalTemplateWithFakeHome({ + templatePath: `~/${templateDirName}`, + projectDir, + }); + + const stats = await fsp.lstat(path.join(projectDir, 'serverless.yml')); + expect(stats.isFile()).to.equal(true); + } finally { + await remove(fakeHome); + await remove(projectDir); + } + }); + + it('should stop before copying when template path expansion fails', async () => { + const expansionError = new ServerlessError( + 'Cannot expand path "~/template": home directory could not be resolved.', + 'HOME_DIRECTORY_UNAVAILABLE' + ); + const copyStub = sinon.stub().resolves(); + const createFromLocalTemplateWithFailingUntildify = proxyquire( + '../../../../lib/utils/create-from-local-template', + { + './untildify': sinon.stub().throws(expansionError), + './fs/copy': copyStub, + } + ); + + try { + await createFromLocalTemplateWithFailingUntildify({ + templatePath: '~/template', + projectDir: path.join(getTmpDirPath(), 'some-service'), + }); + } catch (error) { + expect(error).to.equal(expansionError); + expect(copyStub).to.not.have.been.called; + return; + } + + throw new Error('Expected createFromLocalTemplate to reject'); + }); + it('skips symlinks when creating from a local template', async function () { const tmpRoot = getTmpDirPath(); const templatePath = path.join(tmpRoot, 'template'); diff --git a/test/unit/lib/utils/download-template-from-repo.test.js b/test/unit/lib/utils/download-template-from-repo.test.js index 6c4f7fd99..1628a07df 100644 --- a/test/unit/lib/utils/download-template-from-repo.test.js +++ b/test/unit/lib/utils/download-template-from-repo.test.js @@ -6,6 +6,7 @@ const path = require('path'); const os = require('os'); const proxyquire = require('proxyquire'); const chai = require('chai'); +const ServerlessError = require('../../../../lib/serverless-error'); const { expect } = chai; const { getTmpDirPath } = require('../../../utils/fs'); @@ -13,6 +14,14 @@ const { getTmpDirPath } = require('../../../utils/fs'); const writeFileSync = require('../../../../lib/utils/fs/write-file-sync'); const readFileSync = require('../../../../lib/utils/fs/read-file-sync'); +const loadUntildifyWithHome = (homeDirectory) => + proxyquire('../../../../lib/utils/untildify', { + os: { + homedir: () => homeDirectory, + userInfo: () => ({ username: 'test-user' }), + }, + }); + describe('downloadTemplateFromRepo', () => { let downloadTemplateFromRepo; let spawnStub; @@ -291,6 +300,100 @@ describe('downloadTemplateFromRepo', () => { ); }); + it('should expand a home-relative download path', async () => { + const url = 'https://github.com/johndoe/service-to-be-downloaded'; + const fakeHome = getTmpDirPath(); + const downloadPath = '~/nested/custom-target-directory'; + const targetPath = path.join(fakeHome, 'nested', 'custom-target-directory'); + const downloadTemplateFromRepoWithFakeHome = proxyquire( + '../../../../lib/utils/download-template-from-repo', + { + './serverless-utils/download': downloadStub, + './spawn': spawnStub, + './fs/remove': { removeSync: removeSyncStub }, + './untildify': loadUntildifyWithHome(fakeHome), + } + ).downloadTemplateFromRepo; + + try { + downloadStub.callsFake(async (downloadUrl, destinationPath) => { + expect(downloadUrl).to.equal(`${url}/archive/master.zip`); + expect(destinationPath).to.equal(targetPath); + writeFileSync(path.join(destinationPath, 'serverless.yml'), 'service: service-name'); + }); + + return expect( + downloadTemplateFromRepoWithFakeHome(url, undefined, downloadPath) + ).to.be.fulfilled.then((serviceName) => { + const yml = readFileSync(path.join(targetPath, 'serverless.yml')); + expect(yml.service).to.equal('custom-target-directory'); + expect(serviceName).to.equal('service-to-be-downloaded'); + }); + } finally { + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); + + it('should stop before downloading when download path expansion fails', async () => { + const url = 'https://github.com/johndoe/service-to-be-downloaded'; + const expansionError = new ServerlessError( + 'Cannot expand path "~/target": home directory could not be resolved.', + 'HOME_DIRECTORY_UNAVAILABLE' + ); + const downloadTemplateFromRepoWithFailingUntildify = proxyquire( + '../../../../lib/utils/download-template-from-repo', + { + './serverless-utils/download': downloadStub, + './spawn': spawnStub, + './fs/remove': { removeSync: removeSyncStub }, + './untildify': sinon.stub().throws(expansionError), + } + ).downloadTemplateFromRepo; + + try { + await downloadTemplateFromRepoWithFailingUntildify(url, undefined, '~/target'); + } catch (error) { + expect(error).to.equal(expansionError); + expect(downloadStub).to.not.have.been.called; + expect(spawnStub).to.not.have.been.called; + return; + } + + throw new Error('Expected downloadTemplateFromRepo to reject'); + }); + + it('should report the original home-relative path when the expanded target exists', async () => { + const url = 'https://github.com/johndoe/service-to-be-downloaded'; + const fakeHome = getTmpDirPath(); + const downloadPath = '~/existing-service'; + const expandedPath = path.join(fakeHome, 'existing-service'); + const downloadTemplateFromRepoWithFakeHome = proxyquire( + '../../../../lib/utils/download-template-from-repo', + { + './serverless-utils/download': downloadStub, + './spawn': spawnStub, + './fs/remove': { removeSync: removeSyncStub }, + './untildify': loadUntildifyWithHome(fakeHome), + } + ).downloadTemplateFromRepo; + fs.mkdirSync(expandedPath, { recursive: true }); + + try { + await downloadTemplateFromRepoWithFakeHome(url, undefined, downloadPath); + } catch (error) { + expect(error).to.have.property('code', 'TARGET_FOLDER_ALREADY_EXISTS'); + expect(error).to.have.property( + 'message', + 'A folder named "~/existing-service" already exists.' + ); + return; + } finally { + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + + throw new Error('Expected downloadTemplateFromRepo to reject'); + }); + it('should treat --name as a literal target directory name when --path is omitted', async () => { const url = 'https://github.com/johndoe/service-to-be-downloaded'; const name = '~/service'; diff --git a/test/unit/lib/utils/untildify.test.js b/test/unit/lib/utils/untildify.test.js new file mode 100644 index 000000000..d2008d5e8 --- /dev/null +++ b/test/unit/lib/utils/untildify.test.js @@ -0,0 +1,191 @@ +'use strict'; + +const proxyquire = require('proxyquire').noCallThru().noPreserveCache(); +const sinon = require('sinon'); + +const { expect } = require('chai'); + +const loadUntildify = ({ + homeDirectory = '/home/test-user', + username = 'test-user', + homedirError, + userInfoError, +} = {}) => { + const osStub = { + homedir: sinon.stub(), + userInfo: sinon.stub(), + }; + if (homedirError) osStub.homedir.throws(homedirError); + else osStub.homedir.returns(homeDirectory); + if (userInfoError) osStub.userInfo.throws(userInfoError); + else osStub.userInfo.returns({ username }); + + const untildify = proxyquire('../../../../lib/utils/untildify', { + os: osStub, + }); + + return { untildify, osStub }; +}; + +const expectErrorCode = (fn, code) => { + try { + fn(); + } catch (error) { + expect(error).to.have.property('code', code); + return error; + } + + throw new Error(`Expected ${code} error`); +}; + +describe('test/unit/lib/utils/untildify.test.js', () => { + it('throws on non-string input', () => { + const { untildify, osStub } = loadUntildify(); + + expect(() => untildify()).to.throw(TypeError, 'Expected a string, got undefined'); + expect(() => untildify(null)).to.throw(TypeError, 'Expected a string, got object'); + expect(() => untildify(1)).to.throw(TypeError, 'Expected a string, got number'); + + expect(osStub.homedir).to.not.have.been.called; + expect(osStub.userInfo).to.not.have.been.called; + }); + + it('expands regular home-relative paths', () => { + const { untildify, osStub } = loadUntildify(); + + expect(untildify('~')).to.equal('/home/test-user'); + expect(untildify('~/service')).to.equal('/home/test-user/service'); + expect(untildify('~\\service')).to.equal('/home/test-user\\service'); + + expect(osStub.homedir).to.have.been.calledOnce; + expect(osStub.userInfo).to.not.have.been.called; + }); + + it('returns paths unchanged when they do not start with an expandable tilde', () => { + const { untildify, osStub } = loadUntildify(); + + expect(untildify('service')).to.equal('service'); + expect(untildify('/tmp/~service')).to.equal('/tmp/~service'); + expect(untildify('./~service')).to.equal('./~service'); + + expect(osStub.homedir).to.not.have.been.called; + expect(osStub.userInfo).to.not.have.been.called; + }); + + it('rejects another user home path', () => { + const { untildify, osStub } = loadUntildify({ username: 'current-user' }); + + expectErrorCode(() => untildify('~other-user/service'), 'UNSUPPORTED_HOME_DIRECTORY_EXPANSION'); + + expect(osStub.homedir).to.not.have.been.called; + expect(osStub.userInfo).to.have.been.calledOnce; + }); + + it('expands current user home paths', () => { + const { untildify, osStub } = loadUntildify({ + homeDirectory: '/Users/current-user', + username: 'current-user', + }); + + expect(untildify('~current-user')).to.equal('/Users/current-user'); + expect(untildify('~current-user/service')).to.equal('/Users/current-user/service'); + expect(untildify('~current-user\\service')).to.equal('/Users/current-user\\service'); + + expect(osStub.homedir).to.have.been.calledOnce; + expect(osStub.userInfo).to.have.been.calledOnce; + }); + + it('caches the home directory after the first call', () => { + const osStub = { + homedir: sinon.stub(), + userInfo: sinon.stub().returns({ username: 'test-user' }), + }; + osStub.homedir.onFirstCall().returns('/home/first'); + osStub.homedir.onSecondCall().returns('/home/second'); + + const untildify = proxyquire('../../../../lib/utils/untildify', { + os: osStub, + }); + + expect(untildify('~/one')).to.equal('/home/first/one'); + expect(untildify('~/two')).to.equal('/home/first/two'); + + expect(osStub.homedir).to.have.been.calledOnce; + }); + + it('caches the current username after the first user-home lookup', () => { + const osStub = { + homedir: sinon.stub().returns('/home/first-user'), + userInfo: sinon.stub(), + }; + osStub.userInfo.onFirstCall().returns({ username: 'first-user' }); + osStub.userInfo.onSecondCall().returns({ username: 'second-user' }); + + const untildify = proxyquire('../../../../lib/utils/untildify', { + os: osStub, + }); + + expect(untildify('~first-user/project')).to.equal('/home/first-user/project'); + expectErrorCode( + () => untildify('~second-user/project'), + 'UNSUPPORTED_HOME_DIRECTORY_EXPANSION' + ); + + expect(osStub.userInfo).to.have.been.calledOnce; + }); + + it('throws for regular tilde paths when no home directory is available', () => { + const { untildify, osStub } = loadUntildify({ homeDirectory: '' }); + + expectErrorCode(() => untildify('~/service'), 'HOME_DIRECTORY_UNAVAILABLE'); + + expect(osStub.homedir).to.have.been.calledOnce; + expect(osStub.userInfo).to.not.have.been.called; + }); + + it('throws for current user home paths when no home directory is available', () => { + const { untildify, osStub } = loadUntildify({ homeDirectory: '', username: 'test-user' }); + + expectErrorCode(() => untildify('~test-user/service'), 'HOME_DIRECTORY_UNAVAILABLE'); + + expect(osStub.homedir).to.have.been.calledOnce; + expect(osStub.userInfo).to.have.been.calledOnce; + }); + + it('throws a controlled error when home directory lookup fails', () => { + const { untildify } = loadUntildify({ homedirError: new Error('home lookup failed') }); + + const error = expectErrorCode(() => untildify('~/service'), 'HOME_DIRECTORY_UNAVAILABLE'); + + expect(error.message).to.include('home lookup failed'); + }); + + it('throws a controlled error when current user lookup fails', () => { + const { untildify, osStub } = loadUntildify({ + userInfoError: new Error('user lookup failed'), + }); + + const error = expectErrorCode( + () => untildify('~test-user/service'), + 'CURRENT_USER_UNAVAILABLE' + ); + + expect(error.message).to.include('user lookup failed'); + expect(osStub.homedir).to.not.have.been.called; + }); + + it('throws a controlled error when current username is unavailable', () => { + const { untildify, osStub } = loadUntildify({ username: '' }); + + expectErrorCode(() => untildify('~test-user/service'), 'CURRENT_USER_UNAVAILABLE'); + + expect(osStub.homedir).to.not.have.been.called; + expect(osStub.userInfo).to.have.been.calledOnce; + }); + + it('expands home paths without interpreting replacement tokens in the home directory', () => { + const { untildify } = loadUntildify({ homeDirectory: '/home/$&user' }); + + expect(untildify('~/service')).to.equal('/home/$&user/service'); + }); +});