using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;
namespace Unity.Pipeline.Editor.BuildProcessors
{
///
/// Build processor for runtime Pipeline support. Two responsibilities:
/// - Validates RuntimePipelineManager components in build scenes (security settings) before
/// allowing builds with runtime Pipeline enabled.
/// - Bakes the project's hot reload scope (Assets + loaded package locations) into the manager
/// in each build scene. A running Player cannot resolve the project layout, so the absolute
/// roots it is allowed to hot reload from must be captured at build time.
/// Components control their behavior directly without conditional compilation.
///
#if UNITY_6000_3_OR_NEWER
public class PipelineRuntimeBuildProcessor : IPreprocessBuildWithContext, IProcessSceneWithReport
#else
public class PipelineRuntimeBuildProcessor : IPreprocessBuildWithReport, IProcessSceneWithReport
#endif
{
public int callbackOrder => 0;
#if UNITY_6000_3_OR_NEWER
public void OnPreprocessBuild(BuildCallbackContext ctx)
#else
public void OnPreprocessBuild(BuildReport report)
#endif
{
// Integrity gate: fail the build if a bundled Roslyn DLL was swapped or modified.
VerifyBundledChecksums();
// Note: this opens and closes scene which invalidate all managers.
// Find RuntimePipelineManager components in build scenes
var managers = FindRuntimeManagersInBuildScenes();
if (managers.Count == 0)
{
Debug.LogWarning("Pipeline: No RuntimePipelineManager components found in build scenes. Pipeline will be disabled in Player builds.");
return;
}
if (managers.Count > 1)
{
var sceneNames = string.Join(", ", managers.Select(m => m.gameObject.scene.name + "/" + m.gameObject.name));
Debug.LogWarning($"Pipeline: Multiple RuntimePipelineManager components found in build: {sceneNames}. Only the first enabled component will be used.");
}
// Find enabled managers
var enabledManagers = managers.Where(m => m.enableInBuilds).ToList();
if (enabledManagers.Count == 0)
{
Debug.LogWarning("Pipeline: RuntimePipelineManager components found, but all have enableInBuilds = false. Pipeline will be disabled in Player builds.");
return;
}
if (enabledManagers.Count > 1)
{
var enabledNames = string.Join(", ", enabledManagers.Select(m => m.gameObject.scene.name + "/" + m.gameObject.name));
Debug.LogWarning($"Pipeline: Multiple enabled RuntimePipelineManager components found: {enabledNames}. Using the first one.");
}
var activeManager = enabledManagers[0];
// Validate the active manager configuration
var validationResult = activeManager.ValidateConfiguration();
if (!validationResult.IsValid)
{
throw new BuildFailedException($"Pipeline: Runtime configuration validation failed for {activeManager.gameObject.scene.name}/{activeManager.gameObject.name}: {validationResult.Message}");
}
if (validationResult.Level == "warning")
{
Debug.LogWarning($"Pipeline: Runtime configuration warning for {activeManager.gameObject.scene.name}/{activeManager.gameObject.name}: {validationResult.Message}");
if (!EditorUserBuildSettings.development)
{
Debug.LogWarning("Pipeline: Security warnings detected in release build. Consider reviewing configuration.");
}
}
Debug.Log($"Pipeline: Runtime server ENABLED in build for {activeManager.gameObject.scene.name}/{activeManager.gameObject.name}");
}
///
/// Find all RuntimePipelineManager components in scenes that will be included in the build.
///
private List FindRuntimeManagersInBuildScenes()
{
var managers = new List();
var buildScenes = EditorBuildSettings.scenes.Where(s => s.enabled).ToArray();
foreach (var buildScene in buildScenes)
{
var scene = EditorSceneManager.OpenScene(buildScene.path, OpenSceneMode.Additive);
var sceneManagers = scene.GetRootGameObjects()
.SelectMany(go => go.GetComponentsInChildren(true))
.ToArray();
managers.AddRange(sceneManagers);
}
return managers;
}
///
/// Bake the project's hot reload roots into the RuntimePipelineManager of each build scene.
/// Edits the temporary build copy of the scene, so the user's saved scene is untouched.
///
public void OnProcessScene(Scene scene, BuildReport report)
{
// report is null when this runs on entering Play Mode; only bake during real builds.
if (report == null)
{
return;
}
var roots = CollectProjectRoots();
foreach (var go in scene.GetRootGameObjects())
{
foreach (var manager in go.GetComponentsInChildren(true))
{
manager.SetAllowedReloadRoots(roots);
}
}
}
///
/// Absolute roots considered in-scope for runtime hot reload: the project's Assets folder
/// plus the resolved location of every package loaded into the project (a local package may
/// live anywhere on disk, not only under Packages).
///
public static List CollectProjectRoots()
{
var roots = new List { Path.GetFullPath(Application.dataPath) };
foreach (var package in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages())
{
if (!string.IsNullOrEmpty(package.resolvedPath))
{
roots.Add(Path.GetFullPath(package.resolvedPath));
}
}
return roots;
}
// Relative location of the bundled Roslyn DLLs + their integrity manifest within the package.
private const string CodeAnalysisRelDir = "Runtime/Plugins/CodeAnalysis";
private const string ChecksumsFileName = "CHECKSUMS";
///
/// Verify the bundled Roslyn DLLs against the committed CHECKSUMS manifest, locating them
/// relative to this package on disk. Throws on any
/// mismatch so a tampered/swapped DLL cannot be built into a player.
///
public static void VerifyBundledChecksums()
{
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(
typeof(PipelineRuntimeBuildProcessor).Assembly);
if (packageInfo == null || string.IsNullOrEmpty(packageInfo.resolvedPath))
{
throw new BuildFailedException(
"Pipeline: could not locate the com.unity.pipeline package on disk to verify " +
"bundled Roslyn DLL integrity. Aborting build.");
}
var codeAnalysisDir = Path.Combine(packageInfo.resolvedPath, CodeAnalysisRelDir);
var checksumsPath = Path.Combine(codeAnalysisDir, ChecksumsFileName);
var error = VerifyChecksums(codeAnalysisDir, checksumsPath);
if (error != null)
{
throw new BuildFailedException($"Pipeline: bundled Roslyn DLL integrity check failed. {error}");
}
}
///
/// Core, side-effect-free integrity check (so it is directly unit-testable). Returns null
/// when every DLL listed in exists under
/// with a matching SHA-256 and no unlisted DLL is present;
/// otherwise returns a human-readable error describing the first problem found.
///
public static string VerifyChecksums(string codeAnalysisDir, string checksumsPath)
{
if (!Directory.Exists(codeAnalysisDir))
return $"DLL directory not found: {codeAnalysisDir}";
if (!File.Exists(checksumsPath))
return $"CHECKSUMS manifest not found: {checksumsPath}";
var expected = ParseChecksums(checksumsPath);
if (expected.Count == 0)
return $"CHECKSUMS manifest has no entries: {checksumsPath}";
// Every listed DLL must exist and match.
foreach (var entry in expected)
{
var dllPath = Path.Combine(codeAnalysisDir, entry.Key);
if (!File.Exists(dllPath))
return $"listed DLL is missing: {entry.Key}";
var actual = ComputeSha256(dllPath);
if (!string.Equals(actual, entry.Value, StringComparison.OrdinalIgnoreCase))
return $"hash mismatch for {entry.Key} (expected {entry.Value}, got {actual})";
}
// No unlisted DLL may sit alongside them (guards against an injected extra assembly).
foreach (var dllPath in Directory.GetFiles(codeAnalysisDir, "*.dll"))
{
var name = Path.GetFileName(dllPath);
if (!expected.ContainsKey(name))
return $"unexpected DLL not listed in CHECKSUMS: {name}";
}
return null;
}
/// SHA-256 of a file as a lowercase hex string.
public static string ComputeSha256(string filePath)
{
using (var sha = SHA256.Create())
using (var stream = File.OpenRead(filePath))
{
var hash = sha.ComputeHash(stream);
var sb = new StringBuilder(hash.Length * 2);
foreach (var b in hash)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
// Parse " # comment" lines, skipping blanks and '#' comment lines.
private static Dictionary ParseChecksums(string checksumsPath)
{
var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var raw in File.ReadAllLines(checksumsPath))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#"))
continue;
var tokens = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length < 2)
continue;
result[tokens[1]] = tokens[0];
}
return result;
}
}
}