Editor/Trust/PackageHasher.cs
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;

namespace Sandbox.SecBox;

// SHA-256 over a deterministic stream of (relative path, file bytes) for every
// .dll, .cs, and .razor file inside the package folder. Ordering by
// case-insensitive relative path makes the hash reproducible across machines.
// Any byte change in any covered file changes the hash; trust decisions are
// keyed by it, so an attacker re-uploading a tampered version with the same
// PackageIdent/Version cannot inherit a prior TrustAlways.
public static class PackageHasher
{
	static readonly string[] HashedExtensions = { ".dll", ".cs", ".razor", ".cshtml" };

	public static string HashFolder( string folder )
	{
		if ( !Directory.Exists( folder ) )
			return "missing:" + folder;

		using var sha = SHA256.Create();

		var files = Directory.EnumerateFiles( folder, "*", SearchOption.AllDirectories )
			.Where( p => HashedExtensions.Contains( Path.GetExtension( p ).ToLowerInvariant() ) )
			.Select( p => (rel: Path.GetRelativePath( folder, p ).Replace( '\\', '/' ).ToLowerInvariant(), full: p) )
			.OrderBy( t => t.rel, StringComparer.Ordinal )
			.ToList();

		using var stream = new MemoryStream();
		foreach ( var (rel, full) in files )
		{
			var pathBytes = Encoding.UTF8.GetBytes( rel + "\n" );
			stream.Write( pathBytes, 0, pathBytes.Length );
			try
			{
				var contentBytes = File.ReadAllBytes( full );
				stream.Write( contentBytes, 0, contentBytes.Length );
			}
			catch
			{
				var marker = Encoding.UTF8.GetBytes( "[unreadable]\n" );
				stream.Write( marker, 0, marker.Length );
			}
		}

		stream.Position = 0;
		var hash = sha.ComputeHash( stream );

		var sb = new StringBuilder( hash.Length * 2 );
		for ( int i = 0; i < hash.Length; i++ )
			sb.Append( hash[i].ToString( "x2" ) );
		return sb.ToString();
	}
}