From b3480cfdd5ed2fa687bc80d7bda89812ca0cf487 Mon Sep 17 00:00:00 2001 From: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:28:06 +0200 Subject: [PATCH] Respect NEXT_HASH_SALT for server side assetsHashes (#95738) Also respect the hash salt for `assetsHashes` --- crates/next-api/src/next_server_nft.rs | 11 ++-- crates/next-api/src/nft.rs | 10 ++-- crates/next-api/src/nft_json.rs | 17 +++++-- crates/next-api/src/paths.rs | 2 +- crates/next-api/src/routes_hashes_manifest.rs | 29 ++++++++--- crates/next-api/src/server_actions.rs | 31 +++++++++--- .../next/src/build/adapter/build-complete.ts | 26 +++++++--- .../adapter-content-hashes.test.ts | 50 +++++++++++++++++++ turbopack/crates/turbo-tasks-fs/src/lib.rs | 9 ++-- turbopack/crates/turbopack-core/src/asset.rs | 10 ++-- 10 files changed, 155 insertions(+), 40 deletions(-) diff --git a/crates/next-api/src/next_server_nft.rs b/crates/next-api/src/next_server_nft.rs index 56d1b83ac6c..972cd0ad659 100644 --- a/crates/next-api/src/next_server_nft.rs +++ b/crates/next-api/src/next_server_nft.rs @@ -115,12 +115,15 @@ impl Asset for ServerNftJsonAsset { ) .connect(); + let hash_salt = this.project.next_config().output_hash_salt(); + let mut server_output_assets = traced_modules_for_entries( module_graph, Modules::empty(), self.entries(), Some(self.ignores()), None, + hash_salt, ) .await? .iter() @@ -133,7 +136,7 @@ impl Asset for ServerNftJsonAsset { .await? .context("NFT module has no content")? .content() - .hash(HashAlgorithm::Xxh3Hash128Hex) + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) .await?, )) }) @@ -150,7 +153,7 @@ impl Asset for ServerNftJsonAsset { .context("failed to compute relative path for server NFT JSON")?, module_path .read() - .hash(HashAlgorithm::Xxh3Hash128Hex) + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) .await?, )); @@ -170,7 +173,9 @@ impl Asset for ServerNftJsonAsset { base_dir .get_relative_path_to(file) .context("failed to compute relative path for server NFT JSON")?, - file.read().hash(HashAlgorithm::Xxh3Hash128Hex).await?, + file.read() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + .await?, )) } } diff --git a/crates/next-api/src/nft.rs b/crates/next-api/src/nft.rs index ef8162bd398..f09e23c000a 100644 --- a/crates/next-api/src/nft.rs +++ b/crates/next-api/src/nft.rs @@ -67,6 +67,7 @@ pub async fn trace_endpoint( async { let project_path = project.project_path().owned().await?; let next_config = project.next_config(); + let hash_salt = next_config.output_hash_salt(); let output_file_tracing_includes = next_config .output_file_tracing_includes(project_path.clone()) @@ -83,10 +84,11 @@ pub async fn trace_endpoint( .await? .map(|v| *v), Some(next_config.config_file_path(project_path.clone())), + hash_salt, ) .await?; - let module_data = traced_module_data_for_graph(*module_graph, traced_entries) + let module_data = traced_module_data_for_graph(*module_graph, traced_entries, hash_salt) .to_resolved() .await?; let module_paths = module_data.await?.idents; @@ -271,10 +273,11 @@ pub async fn traced_modules_for_entries( traced_entries: Vc, exclude_glob: Option>, forbidden_path: Option>, + hash_salt: Vc, ) -> Result> { let exclude_glob_and_module_idents = if let Some(exclude_glob) = exclude_glob { let exclude_glob = exclude_glob.await?; - let data = traced_module_data_for_graph(module_graph, traced_entries).await?; + let data = traced_module_data_for_graph(module_graph, traced_entries, hash_salt).await?; Some((exclude_glob, data.idents.await?)) } else { None @@ -379,6 +382,7 @@ pub struct TracedModuleData { pub async fn traced_module_data_for_graph( module_graph: Vc, traced_entries: Vc, + hash_salt: Vc, ) -> Result> { // This function is very similar to traced_modules_for_entries, but doesn't apply the glob and // is executed only once for the whole graph. @@ -420,7 +424,7 @@ pub async fn traced_module_data_for_graph( .await? .context("NFT module has no content")? .content() - .hash(HashAlgorithm::Xxh3Hash128Hex) + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) .await?, ), )) diff --git a/crates/next-api/src/nft_json.rs b/crates/next-api/src/nft_json.rs index 957b3c83462..6d003c8adcf 100644 --- a/crates/next-api/src/nft_json.rs +++ b/crates/next-api/src/nft_json.rs @@ -119,6 +119,7 @@ impl Asset for NftJsonAsset { let output_root_ref = this.project.output_fs().root().await?; let project_root_ref = this.project.project_fs().root().await?; let next_config = this.project.next_config(); + let hash_salt = next_config.output_hash_salt(); let client_root = this.project.client_fs().root(); let client_root = client_root.owned().await?; @@ -173,7 +174,11 @@ impl Asset for NftJsonAsset { let (referenced_chunk_path, hash) = match referenced { AssetOrModule::Asset(v) => ( Either::Left(v.path().await?), - Either::Left(v.content().hash(HashAlgorithm::Xxh3Hash128Hex).await?), + Either::Left( + v.content() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + .await?, + ), ), AssetOrModule::Module(v) => { let ident = module_data @@ -235,7 +240,10 @@ impl Asset for NftJsonAsset { Ok(( relative_path, Either::Left( - file_path.read().hash(HashAlgorithm::Xxh3Hash128Hex).await?, + file_path + .read() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + .await?, ), )) }) @@ -266,7 +274,10 @@ impl Asset for NftJsonAsset { // non-adapter consumers (which includes output:standalone) don't experience a breaking // change, but instead we just add it as a separate field that only build-complete // reads. - let entry_hash = chunk.content().hash(HashAlgorithm::Xxh3Hash128Hex).await?; + let entry_hash = chunk + .content() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + .await?; let json = json!({ "version": 1, "files": files, diff --git a/crates/next-api/src/paths.rs b/crates/next-api/src/paths.rs index 3898419e842..b21dc5b98ea 100644 --- a/crates/next-api/src/paths.rs +++ b/crates/next-api/src/paths.rs @@ -46,7 +46,7 @@ async fn asset_path( } else { asset .content() - .hash(HashAlgorithm::Xxh3Hash128Hex) + .hash(no_hash_salt(), HashAlgorithm::Xxh3Hash128Hex) .owned() .await? }; diff --git a/crates/next-api/src/routes_hashes_manifest.rs b/crates/next-api/src/routes_hashes_manifest.rs index 4fa51d728cc..00cd2feaafe 100644 --- a/crates/next-api/src/routes_hashes_manifest.rs +++ b/crates/next-api/src/routes_hashes_manifest.rs @@ -5,7 +5,7 @@ use turbo_tasks::{FxIndexMap, FxIndexSet, ResolvedVc, TryFlatJoinIterExt, TryJoi use turbo_tasks_fs::{FileContent, FileSystemPath}; use turbo_tasks_hash::{DeterministicHash, HashAlgorithm, Xxh3Hash64Hasher, hash_xxh3_hash64}; use turbopack_core::{ - asset::{Asset, AssetContent}, + asset::{Asset, AssetContent, no_hash_salt}, module::{Module, Modules}, module_graph::{GraphTraversalAction, ModuleGraph}, output::{ @@ -62,7 +62,7 @@ pub async fn endpoints_outputs(endpoints: Vc) -> Result) -> Result> { +pub async fn outputs_hash(outputs: Vc, hash_salt: Vc) -> Result> { let output_assets = expand_output_assets( outputs .await? @@ -73,7 +73,11 @@ pub async fn outputs_hash(outputs: Vc) -> Result> { .await?; let outputs_hashes = output_assets .iter() - .map(|asset| asset.content().hash(HashAlgorithm::Xxh3Hash128Hex)) + .map(|asset| { + asset + .content() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + }) .try_join() .await?; @@ -121,7 +125,11 @@ pub async fn endpoints_entry_modules( } #[turbo_tasks::function] -pub async fn sources_hash(module_graph: Vc, modules: Vc) -> Result> { +pub async fn sources_hash( + module_graph: Vc, + modules: Vc, + hash_salt: Vc, +) -> Result> { let modules = modules.await?; let mut all_modules = FxIndexSet::default(); @@ -144,7 +152,11 @@ pub async fn sources_hash(module_graph: Vc, modules: Vc) - .try_flat_join() .await? .into_iter() - .map(|source| source.content().hash(HashAlgorithm::Xxh3Hash128Hex)) + .map(|source| { + source + .content() + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) + }) .try_join() .await?; @@ -181,6 +193,7 @@ impl RoutesHashesManifestAsset { impl Asset for RoutesHashesManifestAsset { #[turbo_tasks::function] async fn content(&self) -> Result> { + let hash_salt = no_hash_salt(); let module_graphs = self.project.whole_app_module_graphs().await?; let base_module_graph = *module_graphs.base; let full_module_graph = *module_graphs.full; @@ -195,8 +208,9 @@ impl Asset for RoutesHashesManifestAsset { sources_hash( full_module_graph, endpoint_entry_modules(base_module_graph, *entry.endpoint), + hash_salt, ), - outputs_hash(endpoint_outputs(*entry.endpoint)), + outputs_hash(endpoint_outputs(*entry.endpoint), hash_salt), ) } else { let endpoints = Vc::cell(primary.iter().map(|entry| entry.endpoint).collect()); @@ -204,8 +218,9 @@ impl Asset for RoutesHashesManifestAsset { sources_hash( full_module_graph, endpoints_entry_modules(base_module_graph, endpoints), + hash_salt, ), - outputs_hash(endpoints_outputs(endpoints)), + outputs_hash(endpoints_outputs(endpoints), hash_salt), ) }; entrypoint_hashes.insert(key.as_str(), entry); diff --git a/crates/next-api/src/server_actions.rs b/crates/next-api/src/server_actions.rs index 050b35070e1..7e666eefdfb 100644 --- a/crates/next-api/src/server_actions.rs +++ b/crates/next-api/src/server_actions.rs @@ -231,11 +231,11 @@ impl Asset for ServerActionManifestAsset { let actions_value = self.actions.await?; let async_module_info = self.module_graph.async_module_info(); - let durable_use_cache_entries = *self - .project - .next_config() + let next_config = self.project.next_config(); + let durable_use_cache_entries = *next_config .enable_durable_use_cache_entries(self.project.next_mode()) .await?; + let hash_salt = next_config.output_hash_salt(); let loader_id = self.chunk_item.id().await?; let loader_id = match &loader_id { @@ -279,6 +279,7 @@ impl Asset for ServerActionManifestAsset { *self.module_graph, **module, *self.chunking_context, + hash_salt, ) .await?, ) @@ -361,6 +362,7 @@ async fn compute_subtree_content_hash( module_graph: ResolvedVc, entry: ResolvedVc>, chunking_context: Vc>, + hash_salt: Vc, ) -> Result> { let span = tracing::info_span!( "compute use-cache code hash", @@ -408,8 +410,14 @@ async fn compute_subtree_content_hash( .map(async |m| Ok(format!( " '{}': {}", m.ident_string().await?, - module_hash(*module_graph, chunking_context, async_module_info, **m) - .await? + module_hash( + *module_graph, + chunking_context, + async_module_info, + **m, + hash_salt + ) + .await? ))) .try_join() .await? @@ -419,7 +427,15 @@ async fn compute_subtree_content_hash( let hashes = modules .into_iter() - .map(|m| module_hash(*module_graph, chunking_context, async_module_info, *m)) + .map(|m| { + module_hash( + *module_graph, + chunking_context, + async_module_info, + *m, + hash_salt, + ) + }) .try_join() .await?; @@ -448,6 +464,7 @@ async fn module_hash( chunking_context: ResolvedVc>, async_module_info: ResolvedVc, m: ResolvedVc>, + hash_salt: Vc, ) -> Result> { let ident = m.ident(); let ident_value = ident.await?; @@ -484,7 +501,7 @@ async fn module_hash( .await? .with_context(|| format!("failed to get source for module {ident_str}"))? .content() - .hash(HashAlgorithm::Xxh3Hash128Hex) + .hash(hash_salt, HashAlgorithm::Xxh3Hash128Hex) .await?; Ok(Vc::cell(RcStr::from(deterministic_hash( "", diff --git a/packages/next/src/build/adapter/build-complete.ts b/packages/next/src/build/adapter/build-complete.ts index f38163fba9b..06b283db29f 100644 --- a/packages/next/src/build/adapter/build-complete.ts +++ b/packages/next/src/build/adapter/build-complete.ts @@ -1046,7 +1046,8 @@ export async function handleBuildComplete({ existingOutput.assetsHashes, path.relative(repoRoot, pageFile), pageFile, - bundler + bundler, + config.experimental.outputHashSalt || '' ) continue } @@ -2170,6 +2171,7 @@ async function getSharedNodeAssets({ const pagesSharedNodeAssetsHashes: Record = {} const appPagesSharedNodeAssets: Record = {} const appPagesSharedNodeAssetsHashes: Record = {} + const salt = config.experimental.outputHashSalt || '' const moduleTypes = ['app-page', 'pages'] as const @@ -2201,7 +2203,8 @@ async function getSharedNodeAssets({ pagesSharedNodeAssetsHashes, rootRelativeFilePath, path.join(repoRoot, rootRelativeFilePath), - bundler + bundler, + salt ) } else { await pushAsset( @@ -2209,7 +2212,8 @@ async function getSharedNodeAssets({ appPagesSharedNodeAssetsHashes, rootRelativeFilePath, path.join(repoRoot, rootRelativeFilePath), - bundler + bundler, + salt ) } } @@ -2226,7 +2230,8 @@ async function getSharedNodeAssets({ sharedNodeAssetsHashes, path.relative(repoRoot, setupNodeStubPath), require.resolve('next/dist/build/adapter/setup-node-env.external'), - bundler + bundler, + salt ) // Turbopack handles this automatically and these files are listed in the nft.json files. @@ -2315,7 +2320,8 @@ async function getSharedNodeAssets({ sharedNodeAssetsHashes, path.relative(repoRoot, absoluteFilePath), absoluteFilePath, - bundler + bundler, + salt ) } } @@ -2338,6 +2344,7 @@ async function getSharedNodeAssets({ fileOutputPath, path.join(distDir, 'server', 'instrumentation.js'), bundler, + salt, instrumentationEntryHash ) } @@ -2352,7 +2359,8 @@ async function getSharedNodeAssets({ sharedNodeAssetsHashes, fileOutputPath, filePath, - bundler + bundler, + salt ) } @@ -2372,13 +2380,14 @@ async function pushAsset( targetFilePath: string, sourceFilePath: string, bundler: Bundler, + salt: string, hashOverride?: string ) { if (!(targetFilePath in assets)) { assets[targetFilePath] = sourceFilePath if (bundler === Bundler.Turbopack) { assetsHashes[targetFilePath] = - hashOverride ?? (await hashFile(sourceFilePath)) + hashOverride ?? (await hashFile(salt, sourceFilePath)) } } } @@ -2411,8 +2420,9 @@ async function loadNFT( return { entryHash } } -async function hashFile(filePath: string): Promise { +async function hashFile(salt: string, filePath: string): Promise { const hash = crypto.createHash('sha256') + hash.update(salt) try { // Try symlink first, since readFile just transparently resolves those (or fails if it's a // directory symlink). diff --git a/test/production/deterministic-build/adapter-content-hashes.test.ts b/test/production/deterministic-build/adapter-content-hashes.test.ts index ece2fe51b98..d14d53680c8 100644 --- a/test/production/deterministic-build/adapter-content-hashes.test.ts +++ b/test/production/deterministic-build/adapter-content-hashes.test.ts @@ -61,6 +61,56 @@ import { FILES } from './files' outputs.pagesApi.forEach(validateOutput) outputs.appRoutes.forEach(validateOutput) }) + + it('hashes respect NEXT_HASH_SALT', async () => { + const { + outputs: outputs1, + }: Parameters[0] = await next.readJSON( + 'build-complete.json' + ) + + await next.stop() + next.env.NEXT_HASH_SALT = 'something-else' + await next.build() + + const { + outputs: outputs2, + }: Parameters[0] = await next.readJSON( + 'build-complete.json' + ) + + let functions1 = Object.fromEntries( + [ + ...outputs1.pages, + ...outputs1.pagesApi, + ...outputs1.appPages, + ...outputs1.appRoutes, + ].map((output) => [output.pathname, output.assetsHashes]) + ) + let functions2 = Object.fromEntries( + [ + ...outputs2.pages, + ...outputs2.pagesApi, + ...outputs2.appPages, + ...outputs2.appRoutes, + ].map((output) => [output.pathname, output.assetsHashes]) + ) + + for (const pathname in functions1) { + const function1 = functions1[pathname] + const function2 = functions2[pathname] + for (const file in function1) { + const hash1 = function1[file] + const hash2 = function2[file] + expect(hash1).toBeString() + if (hash1 === hash2) { + throw new Error( + `Hash for ${pathname} file ${file} did not change with NEXT_HASH_SALT: ${hash1}` + ) + } + } + } + }) }) } ) diff --git a/turbopack/crates/turbo-tasks-fs/src/lib.rs b/turbopack/crates/turbo-tasks-fs/src/lib.rs index 5546fc1001e..d99202da651 100644 --- a/turbopack/crates/turbo-tasks-fs/src/lib.rs +++ b/turbopack/crates/turbo-tasks-fs/src/lib.rs @@ -2531,9 +2531,12 @@ impl FileContent { } #[turbo_tasks::function] - pub fn hash(&self, algorithm: HashAlgorithm) -> Vc { - // no_hash_salt - Vc::cell(RcStr::from(deterministic_hash("", self, algorithm))) + pub async fn hash(&self, salt: Vc, algorithm: HashAlgorithm) -> Result> { + Ok(Vc::cell(RcStr::from(deterministic_hash( + &salt.await?, + self, + algorithm, + )))) } /// Converts this [`FileContent`] into a [`PersistedFileContent`] by cloning. diff --git a/turbopack/crates/turbopack-core/src/asset.rs b/turbopack/crates/turbopack-core/src/asset.rs index 65cf0e7ecb3..860dd6bf169 100644 --- a/turbopack/crates/turbopack-core/src/asset.rs +++ b/turbopack/crates/turbopack-core/src/asset.rs @@ -131,14 +131,14 @@ impl AssetContent { } #[turbo_tasks::function] - pub fn hash(&self, algorithm: HashAlgorithm) -> Vc { - match self { - AssetContent::File(content) => content.hash(algorithm), + pub async fn hash(&self, salt: Vc, algorithm: HashAlgorithm) -> Result> { + Ok(match self { + AssetContent::File(content) => content.hash(salt, algorithm), AssetContent::Redirect { target, link_type } => Vc::cell(RcStr::from( // no_hash_salt - deterministic_hash("", (target, link_type), algorithm), + deterministic_hash(&salt.await?, (target, link_type), algorithm), )), - } + }) } /// Compared to [AssetContent::hash], this hashes only the bytes of the file content and