Editor helper that validates and resolves incoming relative file paths for TeamCreate. It enforces that paths are plain relative with '/' separators under the Assets folder, checks segment length and characters, bans drive letters, colons, device names, control characters, trailing dots/spaces and reparse points, and resolves to an absolute path while ensuring the target is strictly inside the project root and that no existing path component is a symbolic link or junction.
using System;
using System.IO;
namespace TeamCreate;
/// <summary>
/// The rules a received file path must pass before anything is written for it. A path comes from another person's machine, so a hostile or
/// buggy host must never be able to name a file outside the project: not by "..", not by a drive letter or a UNC share, not by an NTFS
/// alternate data stream, not by a Windows device name, and not through a symbolic link or junction already sitting inside the project.
/// Only the Assets folder may be written; code, settings and libraries are never received this way.
/// Every refusal is a specific, formal sentence and never repeats the offending path, which may hold control characters or be enormous.
/// </summary>
public static class FilePathRules
{
/// <summary>The only top-level folder a transferred file may land in. Compared case-sensitively: the sender spells it exactly.</summary>
public const string AllowedRoot = "Assets";
private const int MaxPathLength = 240;
private const int MaxSegmentLength = 100;
private static readonly string[] ReservedDeviceNames = { "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$" };
// Characters Windows refuses in a file name. The colon is refused for the whole path earlier, with its own message.
private static readonly char[] InvalidNameCharacters = { '<', '>', '"', '|', '?', '*' };
/// <summary>
/// True when <paramref name="relativePath"/> is a plain relative path with "/" separators below the Assets folder; it is then returned
/// unchanged (nothing is rewritten, so what was validated is exactly what is used). Otherwise false, <paramref name="normalized"/> is null
/// so a caller that ignores the result fails loudly instead of joining an empty string onto a root, and <paramref name="error"/> says why.
/// </summary>
public static bool TryNormalize( string relativePath, out string normalized, out string error )
{
string problem = Validate( relativePath );
if ( problem != null )
{
normalized = null;
error = problem;
return false;
}
normalized = relativePath;
error = null;
return true;
}
/// <summary>
/// Resolves an already-normalised relative path under <paramref name="rootDirectory"/> to a full path, or refuses. It re-runs
/// <see cref="TryNormalize"/> so a caller cannot skip it, requires the result to be strictly inside the root, and refuses when any
/// EXISTING component of the path (including the last) is a symbolic link or junction, because a write through a link inside Assets
/// would land wherever the link points. Nothing is created. The answer is only as fresh as the moment it is given: a writer must still
/// open the file without following links, since a link could appear between this check and the write.
/// </summary>
public static bool TryResolveUnder( string rootDirectory, string normalizedRelativePath, out string fullPath, out string error )
{
fullPath = null;
if ( string.IsNullOrWhiteSpace( rootDirectory ) )
{
error = "The project root directory is empty.";
return false;
}
if ( !TryNormalize( normalizedRelativePath, out string normalized, out error ) )
{
return false;
}
string root;
string candidate;
try
{
root = Path.GetFullPath( rootDirectory );
candidate = Path.GetFullPath( Path.Combine( root, normalized.Replace( '/', Path.DirectorySeparatorChar ) ) );
}
catch ( Exception ex ) when ( ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException )
{
error = "The project root directory or the file path cannot be resolved to a valid location.";
return false;
}
if ( !IsStrictlyInside( root, candidate ) )
{
error = "The file path resolves to a location outside the project root.";
return false;
}
string current = root;
foreach ( string segment in normalized.Split( '/' ) )
{
current = Path.Combine( current, segment );
FileAttributes attributes;
try
{
attributes = File.GetAttributes( current );
}
catch ( Exception ex ) when ( ex is FileNotFoundException || ex is DirectoryNotFoundException )
{
// Nothing exists from here down, so no link can lie deeper on the path.
break;
}
catch ( Exception ex ) when ( ex is IOException || ex is UnauthorizedAccessException )
{
error = "A folder on the file path cannot be inspected, so it cannot be shown to stay inside the project.";
return false;
}
if ( ( attributes & FileAttributes.ReparsePoint ) != 0 )
{
error = "A folder or file on the path is a symbolic link or junction, so a write could leave the project.";
return false;
}
}
fullPath = candidate;
error = null;
return true;
}
/// <summary>
/// True when <paramref name="candidatePath"/> is inside <paramref name="rootDirectory"/> and not the root itself. The root is compared
/// WITH a trailing separator so a sibling that merely starts with the root's name (root "proj", candidate "proj-evil/x") is outside.
/// Case-insensitive where the separator is a backslash (Windows). Internal because a normalised path cannot escape the root, so the
/// check is a second line of defence that no public input reaches; the tests call it directly.
/// </summary>
internal static bool IsStrictlyInside( string rootDirectory, string candidatePath )
{
string root = Path.GetFullPath( rootDirectory );
string candidate = Path.GetFullPath( candidatePath );
if ( !Path.EndsInDirectorySeparator( root ) )
{
root += Path.DirectorySeparatorChar;
}
var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return candidate.Length > root.Length && candidate.StartsWith( root, comparison );
}
/// <summary>The first rule the path breaks as a sentence, or null when it breaks none.</summary>
private static string Validate( string path )
{
if ( string.IsNullOrWhiteSpace( path ) )
{
return "The file path is empty.";
}
if ( path.Length > MaxPathLength )
{
return $"The file path is longer than {MaxPathLength} characters.";
}
foreach ( char c in path )
{
if ( char.IsControl( c ) )
{
return "The file path contains a control character.";
}
}
if ( path.Contains( '\\' ) )
{
return "The file path contains a backslash; only \"/\" may separate folders.";
}
// One rule covers drive letters ("C:/"), drive-relative paths ("C:x") and NTFS alternate data streams ("a.png:stream"):
// none of them has a legitimate use in a file received into the project.
if ( path.Contains( ':' ) )
{
return "The file path contains a colon, which would name a drive or an alternate data stream.";
}
if ( path[0] == '/' )
{
return "The file path must be relative; it must not start with \"/\".";
}
if ( path[path.Length - 1] == '/' )
{
return "The file path must name a file; it must not end with \"/\".";
}
string[] segments = path.Split( '/' );
foreach ( string segment in segments )
{
string problem = ValidateSegment( segment );
if ( problem != null )
{
return problem;
}
}
if ( !string.Equals( segments[0], AllowedRoot, StringComparison.Ordinal ) )
{
return $"The file path must start with the \"{AllowedRoot}\" folder; no other folder may be written.";
}
if ( segments.Length < 2 )
{
return $"The file path must name a file inside \"{AllowedRoot}\".";
}
return null;
}
private static string ValidateSegment( string segment )
{
if ( segment.Length == 0 )
{
return "The file path contains an empty segment (\"//\").";
}
if ( segment == "." || segment == ".." )
{
return "The file path contains a \".\" or \"..\" segment.";
}
if ( segment.Length > MaxSegmentLength )
{
return $"A segment of the file path is longer than {MaxSegmentLength} characters.";
}
// Windows silently drops a trailing space or dot, so "Assets/a." and "Assets/a" would be the same file under two names.
char last = segment[segment.Length - 1];
if ( last == ' ' || last == '.' )
{
return "A segment of the file path ends with a space or a dot, which Windows silently removes.";
}
if ( segment.IndexOfAny( InvalidNameCharacters ) >= 0 )
{
return "A segment of the file path contains a character that is not allowed in Windows file names.";
}
if ( IsReservedDeviceName( segment ) )
{
return "A segment of the file path is a reserved Windows device name.";
}
return null;
}
/// <summary>
/// Windows treats CON, PRN, AUX, NUL, COM1-9 and LPT1-9 as devices in any folder and with any extension ("nul.txt", "aux.tar.gz"), so
/// the part before the FIRST dot decides, ignoring spaces before that dot. The superscript digits (COM¹) are devices too, as are CONIN$ and CONOUT$.
/// </summary>
private static bool IsReservedDeviceName( string segment )
{
int dot = segment.IndexOf( '.' );
string stem = ( dot < 0 ? segment : segment.Substring( 0, dot ) ).TrimEnd( ' ' );
foreach ( string reserved in ReservedDeviceNames )
{
if ( string.Equals( stem, reserved, StringComparison.OrdinalIgnoreCase ) )
{
return true;
}
}
if ( stem.Length == 4 && ( stem.StartsWith( "COM", StringComparison.OrdinalIgnoreCase ) || stem.StartsWith( "LPT", StringComparison.OrdinalIgnoreCase ) ) )
{
char digit = stem[3];
return ( digit >= '1' && digit <= '9' ) || digit == '\u00b9' || digit == '\u00b2' || digit == '\u00b3';
}
return false;
}
}