Conversation
|
|
||
| link_path = os.path.join(destination, *name.split("/")) | ||
| os.makedirs(os.path.dirname(link_path), exist_ok=True) | ||
| utils.create_symlink_or_copy(package_dir, link_path) |
There was a problem hiding this comment.
[BUG] Two closure entries with the same package name silently collapse into one link, and which one wins depends on npm's output order.
_outermost_packages filters a nested copy only when it sits inside another candidate. Both project_root and install_dir are removed from candidates, so a nested copy under the function's own node_modules is never recognised as nested:
closure = [
project_root, # excluded
install_dir, # excluded
project_root/node_modules/lodash, # lodash@1, hoisted for pkg-a
install_dir/node_modules/lodash, # lodash@2, nested because fn pins it
]
Neither lodash path is a prefix of the other, and neither is inside a candidate, so both are returned and both resolve to link_path = <artifacts>/node_modules/lodash. The second utils.create_symlink_or_copy call hits the early return in create_symlink_or_copy (destination.exists() and destination.is_symlink()) and returns without a warning, so one version is dropped. On a host where symlinks fall back to copying, the second call instead raises FileExistsError, is caught, and copytree merges two different versions into the same directory. This is the version-conflict case in a monorepo, which the unit tests only cover for a copy nested inside a sibling package (test_leaves_a_nested_copy_inside_the_dependency_that_pins_it), not one nested under install_dir.
Suggest treating install_dir as a containing directory for the nesting check (a copy under install_dir/node_modules is the function's own and should win at top level) and failing or logging loudly on a genuine name collision rather than letting it resolve by chance.
| # name, and guessing one from the path is how a package lands where node will not find it | ||
| raise ActionFailedError(f"Cannot read the package name of {package_dir}: {ex}") | ||
|
|
||
| link_path = os.path.join(destination, *name.split("/")) |
There was a problem hiding this comment.
[SECURITY] name is read from a third-party dependency's package.json and joined straight into a filesystem path:
link_path = os.path.join(destination, *name.split("/"))
os.makedirs(os.path.dirname(link_path), exist_ok=True)
utils.create_symlink_or_copy(package_dir, link_path)A manifest with "name": "../../../evil" escapes the artifacts directory, and os.makedirs will create the intermediate directories to get there. The registry rejects such names, but nothing here validates what is actually on disk (a vendored or locally-linked package, or a tampered cache). Worth checking that the resolved link_path stays under destination before creating anything:
if os.path.commonpath([os.path.abspath(destination), os.path.abspath(link_path)]) != os.path.abspath(destination):
raise ActionFailedError(f"Package name {name!r} in {package_dir} does not resolve inside node_modules")| # no answer from npm is no basis for leaving anything out, and an over-complete node_modules | ||
| # still runs; link the whole installed tree instead | ||
| LOG.debug("NODEJS linking all of %s into the artifacts", self._project_root) | ||
| utils.create_symlink_or_copy(os.path.join(self._project_root, "node_modules"), destination) |
There was a problem hiding this comment.
[BUG] The closure is None fallback links project_root/node_modules without checking that it exists. os.symlink succeeds against a missing target, so the artifact gets a dangling node_modules symlink — worse than the no-op it replaces. LinkSinglePathAction, which this action supersedes on this path, guards for exactly this:
if not source_path.exists():
LOG.debug("Source path %s does not exist, skipping generating symlink", source_path)
returnReachable when npm ls exits non-zero on a tree that also produced no root node_modules. Adding the same existence check (and skipping) keeps the failure mode at "no dependencies" rather than "broken link".
Building nodejs dependencies in source ignored the project's lockfile: the install step ran `npm update` with `--no-package-lock`, so every build re-resolved the ranges in `package.json` and could upgrade dependencies in the developer's own source tree. On a workspaces project whose root `package-lock.json` pins `lodash` 4.17.20 (manifest `^4.17.20`), one `sam build --build-in-source` left 4.18.1 installed. Behind an opt-in flag while it rolls out. `is_nodejs_monorepo_support_enabled` reads `experimentalNodejsMonorepo` from the `experimental_flags` the workflow is constructed with - sam-cli's `ExperimentalFlag.NodejsMonorepo`, whose config key crosses the boundary as that string, the same way `experimentalBuildPerformance` already does for python_pip. Without the flag every in-source build keeps `npm update --no-package-lock`, and npm is not even asked where the lockfile is. With it, and a lockfile npm will use: | project | before | with the flag | |---|---|---| | has a lockfile | `npm update --no-audit --no-save --omit=dev --no-package-lock --install-links` | `npm install -q --no-audit --no-save --omit=dev --install-links` | | has no lockfile | `npm update ...` | unchanged | Which directory holds the lockfile npm will use is npm's own decision, so `NodejsNpmWorkflow.get_lockfile_path()` asks it: `npm prefix`, run in the install directory, reports the monorepo root for a workspace package and the package itself for any other nested package, whose ancestors' lockfiles npm ignores. Identical on npm 8.19.4, 9.9.4, 10.9.9 and 11.19.0. `NodejsNpmInstallAction` takes back an `install_links` argument. `npm ci` stays gated on a lockfile in the source directory itself - run from a workspace package it empties the workspace root's devDependencies on every npm version. Lockfile-less projects keep `npm update` and its pruning of dependencies removed from the manifest (aws#579); the lockfile-respecting install prunes those as well, so projects with a lockfile lose nothing.
…s monorepo npm hoists a workspace package's dependencies to the monorepo root, so node_modules never appears beside the function and the artifacts link found no source at all - silently producing a deployment package with no dependencies. Ask npm where its project root is, the same question the lockfile lookup asks, and link the artifacts there. Only a project root outside the install directory redirects the link, so anything that is not a workspace member keeps the path it had. Refs aws#933
…o tree Linking the monorepo root's whole node_modules into the artifacts also ships every sibling function's dependencies, because npm hoists them all into one directory. Ask npm which packages this function resolves (npm ls --all --parseable --omit=dev) and link those under their own names. A workspace dependency is reported as its source directory, whose basename is not the package name, so the name comes from its manifest. A nested copy that a dependency pins to another version stays inside that dependency rather than being hoisted, where it would shadow the top-level version. When npm cannot answer, link the whole installed tree: an over-complete node_modules still runs, an empty one does not. Refs aws#933
070a021 to
bb7368b
Compare
| # gets linked, so only the comparisons are normalised. No-op off Windows. | ||
| paths = [os.path.realpath(path) for path in closure] | ||
| excluded = {os.path.normcase(os.path.realpath(d)) for d in (self._project_root, self._install_dir)} | ||
| candidates = [path for path in paths if os.path.normcase(path) not in excluded] |
There was a problem hiding this comment.
[BUG] Re-raising: a nested copy under the function's own node_modules is never recognised as nested, so two closure entries with the same package name collapse into one link and which one wins depends on npm's output order.
install_dir is removed from candidates, so is_nested_in_another can never match it as the containing directory:
closure = [
project_root, # excluded
install_dir, # excluded
install_dir/node_modules/lodash, # lodash@1, pinned by the function
project_root/node_modules/c, # depends on lodash@2
project_root/node_modules/lodash, # lodash@2, hoisted
]
Both lodash paths survive _outermost_packages and both resolve to artifacts/node_modules/lodash. create_symlink_or_copy returns early when the destination already exists as a symlink, so the first one npm happened to print wins and the other version is silently dropped — either the function or c gets the wrong lodash. test_leaves_a_nested_copy_inside_the_dependency_that_pins_it only covers the nested-under-another-dependency case, which does work.
The nesting test needs to consider install_dir (and project_root) as containers even though they are not link candidates, e.g. keep a separate containers = candidates + [project_root, install_dir] list and test is_nested_in_another against that. A same-name collision that survives should at minimum be logged rather than resolved by output order.
| # name, and guessing one from the path is how a package lands where node will not find it | ||
| raise ActionFailedError(f"Cannot read the package name of {package_dir}: {ex}") | ||
|
|
||
| link_path = os.path.join(destination, *name.split("/")) |
There was a problem hiding this comment.
[SECURITY] Re-raising: name comes from a third-party dependency's package.json and is joined straight into a filesystem path, so a hostile manifest can write outside the artifacts directory.
link_path = os.path.join(destination, *name.split("/"))
os.makedirs(os.path.dirname(link_path), exist_ok=True)
utils.create_symlink_or_copy(package_dir, link_path)"name": "../../../evil" yields ["..", "..", "..", "evil"], and os.makedirs will happily create the traversed intermediates before the symlink (or, on the copy fallback, a full copytree) lands outside artifacts_dir. An absolute name ("/tmp/x" → os.path.join discards destination) is worse. Nothing between npm ls and here validates the name.
Validate before use: reject a name that is not exactly pkg or @scope/pkg with no path separators, .., or drive/root prefix in either segment, and assert os.path.commonpath([os.path.realpath(destination), os.path.realpath(link_path)]) == os.path.realpath(destination).
| # no answer from npm is no basis for leaving anything out, and an over-complete node_modules | ||
| # still runs; link the whole installed tree instead | ||
| LOG.debug("NODEJS linking all of %s into the artifacts", self._project_root) | ||
| utils.create_symlink_or_copy(os.path.join(self._project_root, "node_modules"), destination) |
There was a problem hiding this comment.
[BUG] Re-raising: the closure is None fallback links project_root/node_modules without checking it exists, producing a dangling symlink in the artifacts.
create_symlink_or_copy goes straight to os.symlink(Path(source).absolute(), ...), which succeeds against a missing target on POSIX, so the artifact ends up with a node_modules symlink that points nowhere — a worse outcome than the no-op it replaces, since downstream CopyDependenciesAction/copytree will then follow or reproduce a broken link. LinkSinglePathAction, which this action supersedes on this path, guards for exactly this:
if not source_path.exists():
LOG.debug("Source path %s does not exist, skipping generating symlink", source_path)
returnAdd the same existence check before linking (and log at debug/warning so the empty-dependency case is visible). test_links_the_whole_installed_tree_when_npm_cannot_be_asked always creates the tree first, so it does not cover this.
|
|
||
| for package_dir in self._outermost_packages(closure): | ||
| try: | ||
| name = self._osutils.parse_json(os.path.join(package_dir, "package.json"))["name"] |
There was a problem hiding this comment.
[BUG] An aliased dependency is linked under the wrong name. npm supports "lodash4": "npm:lodash@^4.0.0", which installs at node_modules/lodash4 while the on-disk manifest still says "name": "lodash". npm ls --parseable only prints the path, so taking the name from the manifest loses the alias: the artifact gets node_modules/lodash and require("lodash4") fails at runtime with Cannot find module — the same failure this PR is fixing. Two aliases of the same package additionally collide on one destination (see comment 1).
The path already carries the correct name for anything npm installed: the segments after the last node_modules component (lodash4, or @scope/name for a scoped install). The manifest name is only needed for the case the docstring calls out — a workspace dependency reported as its own source directory, which is not under node_modules. Preferring the path-derived name and falling back to the manifest only outside node_modules fixes the alias case and removes the untrusted-name path traversal in comment 2 at the same time.
Issue #, if available: Fixes #933
Description of changes
nodejs_npmwith--build-in-sourceproduces an artifact with no dependencies at all for an npm workspaces monorepo, and reports success. npm hoists a workspace member's dependencies to the monorepo root, so nothing is installed beside the function; the artifacts link looks fornode_modulesnext to the function, finds none, and silently skips. The function then fails at runtime withCannot find module.Opt-in while it rolls out. Both changes are behind
experimentalNodejsMonorepo- sam-cli'sExperimentalFlag.NodejsMonorepo, the same flag #931 uses. Without it npm is not asked where its projectroot is and the artifacts link stays exactly where it is today, which for a monorepo means this bug is
still there: that is deliberate, so a release can carry the fix while users opt into it.
test_without_the_flag_a_monorepo_keeps_the_plain_link_and_npm_is_not_askedpins that side.Two changes, one per commit.
cabe7ee— link the artifacts to wherever npm actually installed.SubprocessNpm.resolve_project_rootasks npm itself (npm prefix, cached per directory), and only a project root outside the install directory redirects the link. npm answers with the install directory itself for anything that is not a workspace member, so every existing path — including the external-manifest one, which links through the source tree — is left exactly as it was.070a021— ship only the function's own dependencies. Linking the whole hoisted root would put every sibling function's dependencies into every artifact.NodejsNpmLinkDependencyClosureActionlinks just the packages the function resolves, each under its own name:@mono/shared)node_modulesstill runsBoth path comparisons go through
os.path.normcase. They compare npm's stdout against a build-computed path, and on Windows two spellings that differ only in case — a drive letter included — name the same directory; reading them as different roots would redirect the link for a project that is not a workspace member, or hoist a nested copy. It is a no-op off Windows.The esbuild workflow needs none of this: it bundles what each entry point imports into the output file, resolving through the hoisted root, so its artifacts were already correct.
New fixture manifests and lockfiles under the
nodejs_npmintegrationtestdata/directory are test data, not a dependency change to this package.Description of how you validated changes
The new integration test fails without the fix. With this branch's tests kept and only the three production files reverted to #931,
test_build_in_source_in_workspaces_monorepo_links_the_hoisted_dependenciesfails on all five supported runtimes withAssertionError: False is not true : the artifacts have no node_modules at all— the #933 symptom exactly.The fixture is a workspaces monorepo with two functions on disjoint dependencies (
minimal-request-promise,ms), a shared workspace package both depend on, and a version conflict the shared package pins so npm keeps a nested copy. Against the real npm, across every supported runtime, the test asserts each function's artifacts carry its own dependency and the shared package but not its sibling's, that the nested conflicting copy stays nested, and thatnode -e "require('./included.js')"succeeds from each artifacts directory — so the artifact is covered, not just the installed tree.Unit tests cover the closure selection (
test_links_only_this_function_s_dependencies,test_links_a_workspace_dependency_under_its_own_name,test_leaves_a_nested_copy_inside_the_dependency_that_pins_it), the no-answer fallback (test_links_the_whole_installed_tree_when_npm_cannot_be_asked), the missing-manifest failure (test_fails_loudly_when_a_reported_package_has_no_manifest), the workflow wiring for both the monorepo and the unchanged non-monorepo path, andnpm prefixresolution including its per-directory caching and that a failure is cached too.black --checkclean.npm prefixand hoisting are npm-version dependent.create_symlink_or_copy, which falls back to a copy whenos.symlinkis not permitted; thewindows-latestlanes on this PR are the first run.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.