unit PathFunc; { Inno Setup Copyright (C) 1997-2025 Jordan Russell Portions by Martijn Laan For conditions of distribution and use, see LICENSE.TXT. This unit provides some path-related functions. } interface function AddBackslash(const S: String): String; function PathChangeExt(const Filename, Extension: String): String; function PathCharCompare(const S1, S2: PChar): Boolean; function PathCharIsSlash(const C: Char): Boolean; function PathCharIsTrailByte(const S: String; const Index: Integer): Boolean; function PathCharLength(const S: String; const Index: Integer): Integer; function PathCombine(const Dir, Filename: String): String; function PathCompare(const S1, S2: String): Integer; function PathDrivePartLength(const Filename: String): Integer; function PathDrivePartLengthEx(const Filename: String; const IncludeSignificantSlash: Boolean): Integer; function PathExpand(const Filename: String): String; overload; function PathExpand(const Filename: String; out ExpandedFilename: String): Boolean; overload; function PathExtensionPos(const Filename: String): Integer; function PathExtractDir(const Filename: String): String; function PathExtractDrive(const Filename: String): String; function PathExtractExt(const Filename: String): String; function PathExtractName(const Filename: String): String; function PathExtractPath(const Filename: String): String; function PathHasInvalidCharacters(const S: String; const AllowDriveLetterColon: Boolean): Boolean; function PathIsRooted(const Filename: String): Boolean; function PathLastChar(const S: String): PChar; function PathLastDelimiter(const Delimiters, S: string): Integer; function PathLowercase(const S: String): String; function PathNormalizeSlashes(const S: String): String; function PathPathPartLength(const Filename: String; const IncludeSlashesAfterPath: Boolean): Integer; function PathPos(Ch: Char; const S: String): Integer; function PathSame(const S1, S2: String): Boolean; function PathStartsWith(const S, AStartsWith: String): Boolean; function PathStrNextChar(const S: PChar): PChar; function PathStrPrevChar(const Start, Current: PChar): PChar; function PathStrScan(const S: PChar; const C: Char): PChar; function RemoveBackslash(const S: String): String; function RemoveBackslashUnlessRoot(const S: String): String; function ValidateAndCombinePath(const ADestDir, AFilename: String; out AResultingPath: String): Boolean; overload; function ValidateAndCombinePath(const ADestDir, AFilename: String): Boolean; overload; implementation {$ZEROBASEDSTRINGS OFF} uses Windows, SysUtils; function AddBackslash(const S: String): String; { Returns S plus a trailing backslash, unless S is an empty string or already ends in a backslash/slash. } begin if (S <> '') and not PathCharIsSlash(PathLastChar(S)^) then Result := S + '\' else Result := S; end; function PathCharLength(const S: String; const Index: Integer): Integer; { Returns the length in characters of the character at Index in S. } begin Result := 1; end; function PathCharIsSlash(const C: Char): Boolean; { Returns True if C is a backslash or slash. } begin Result := (C = '\') or (C = '/'); end; function PathCharIsTrailByte(const S: String; const Index: Integer): Boolean; { Returns False if S[Index] is a single byte character or a lead byte. Returns True otherwise (i.e. it must be a trail byte). } var I: Integer; begin I := 1; while I <= Index do begin if I = Index then begin Result := False; Exit; end; Inc(I, PathCharLength(S, I)); end; Result := True; end; function PathCharCompare(const S1, S2: PChar): Boolean; { Compares two first characters, and returns True if they are equal. } var N, I: Integer; begin N := PathStrNextChar(S1) - S1; if N = PathStrNextChar(S2) - S2 then begin for I := 0 to N-1 do begin if S1[I] <> S2[I] then begin Result := False; Exit; end; end; Result := True; end else Result := False; end; function PathChangeExt(const Filename, Extension: String): String; { Takes Filename, removes any existing extension, then adds the extension specified by Extension and returns the resulting string. } var I: Integer; begin I := PathExtensionPos(Filename); if I = 0 then Result := Filename + Extension else Result := Copy(Filename, 1, I - 1) + Extension; end; function PathCombine(const Dir, Filename: String): String; { Combines a directory and filename into a path. If Dir is empty, it just returns Filename. If Filename is empty, it returns an empty string (ignoring Dir). If Filename begins with a drive letter or slash, it returns Filename (ignoring Dir). If Dir specifies only a drive letter and colon ('c:'), it returns Dir + Filename. Otherwise, it returns the equivalent of AddBackslash(Dir) + Filename. } var I: Integer; begin if (Dir = '') or (Filename = '') or PathIsRooted(Filename) then Result := Filename else begin I := PathCharLength(Dir, 1) + 1; if ((I = Length(Dir)) and (Dir[I] = ':')) or PathCharIsSlash(PathLastChar(Dir)^) then Result := Dir + Filename else Result := Dir + '\' + Filename; end; end; function PathCompare(const S1, S2: String): Integer; { Compares two filenames, and returns 0 if they are equal. } begin Result := CompareStr(PathLowercase(S1), PathLowercase(S2)); end; function PathDrivePartLength(const Filename: String): Integer; begin Result := PathDrivePartLengthEx(Filename, False); end; function PathDrivePartLengthEx(const Filename: String; const IncludeSignificantSlash: Boolean): Integer; { Returns length of the drive portion of Filename, or 0 if there is no drive portion. If IncludeSignificantSlash is True, the drive portion can include a trailing slash if it is significant to the meaning of the path (i.e. 'x:' and 'x:\' are not equivalent, nor are '\' and ''). If IncludeSignificantSlash is False, the function works as follows: 'x:file' -> 2 ('x:') 'x:\file' -> 2 ('x:') '\\server\share\file' -> 14 ('\\server\share') '\file' -> 0 ('') If IncludeSignificantSlash is True, the function works as follows: 'x:file' -> 2 ('x:') 'x:\file' -> 3 ('x:\') '\\server\share\file' -> 14 ('\\server\share') '\file' -> 1 ('\') } var Len, I, C: Integer; begin Len := Length(Filename); { \\server\share } if (Len >= 2) and PathCharIsSlash(Filename[1]) and PathCharIsSlash(Filename[2]) then begin I := 3; C := 0; while I <= Len do begin if PathCharIsSlash(Filename[I]) then begin Inc(C); if C >= 2 then Break; repeat Inc(I); { And skip any additional consecutive slashes: } until (I > Len) or not PathCharIsSlash(Filename[I]); end else Inc(I, PathCharLength(Filename, I)); end; Result := I - 1; Exit; end; { \ } { Note: Test this before 'x:' since '\:stream' means access stream 'stream' on the root directory of the current drive, not access drive '\:' } if (Len >= 1) and PathCharIsSlash(Filename[1]) then begin if IncludeSignificantSlash then Result := 1 else Result := 0; Exit; end; { x: } if Len > 0 then begin I := PathCharLength(Filename, 1) + 1; if (I <= Len) and (Filename[I] = ':') then begin if IncludeSignificantSlash and (I < Len) and PathCharIsSlash(Filename[I+1]) then Result := I+1 else Result := I; Exit; end; end; Result := 0; end; function PathIsRooted(const Filename: String): Boolean; { Returns True if Filename begins with a slash or drive ('x:'). Equivalent to: PathDrivePartLengthEx(Filename, True) <> 0 } var Len, I: Integer; begin Result := False; Len := Length(Filename); if Len > 0 then begin { \ or \\ } if PathCharIsSlash(Filename[1]) then Result := True else begin { x: } I := PathCharLength(Filename, 1) + 1; if (I <= Len) and (Filename[I] = ':') then Result := True; end; end; end; function PathPathPartLength(const Filename: String; const IncludeSlashesAfterPath: Boolean): Integer; { Returns length of the path portion of Filename, or 0 if there is no path portion. Note these differences from Delphi's ExtractFilePath function: - The result will never be less than what PathDrivePartLength returns. If you pass a UNC root path, e.g. '\\server\share', it will return the length of the entire string, NOT the length of '\\server\'. - If you pass in a filename with a reference to an NTFS alternate data stream, e.g. 'abc:def', it will return the length of the entire string, NOT the length of 'abc:'. } var LastCharToKeep, Len, I: Integer; begin Result := PathDrivePartLengthEx(Filename, True); LastCharToKeep := Result; Len := Length(Filename); I := Result + 1; while I <= Len do begin if PathCharIsSlash(Filename[I]) then begin if IncludeSlashesAfterPath then Result := I else Result := LastCharToKeep; Inc(I); end else begin Inc(I, PathCharLength(Filename, I)); LastCharToKeep := I-1; end; end; end; function PathExpand(const Filename: String; out ExpandedFilename: String): Boolean; { Like Delphi's ExpandFileName, but does proper error checking. } var Res: Integer; FilePart: PChar; Buf: array[0..4095] of Char; begin DWORD(Res) := GetFullPathName(PChar(Filename), SizeOf(Buf) div SizeOf(Buf[0]), Buf, FilePart); Result := (Res > 0) and (Res < SizeOf(Buf) div SizeOf(Buf[0])); if Result then SetString(ExpandedFilename, Buf, Res) end; function PathExpand(const Filename: String): String; begin if not PathExpand(Filename, Result) then Result := Filename; end; function PathExtensionPos(const Filename: String): Integer; { Returns index of the last '.' character in the filename portion of Filename, or 0 if there is no '.' in the filename portion. Note: Filename is assumed to NOT include an NTFS alternate data stream name (i.e. 'filename:stream'). } var Len, I: Integer; begin Result := 0; Len := Length(Filename); I := PathPathPartLength(Filename, True) + 1; while I <= Len do begin if Filename[I] = '.' then begin Result := I; Inc(I); end else Inc(I, PathCharLength(Filename, I)); end; end; function PathExtractDir(const Filename: String): String; { Like PathExtractPath, but strips any trailing slashes, unless the resulting path is the root directory of a drive (i.e. 'C:\' or '\'). } var I: Integer; begin I := PathPathPartLength(Filename, False); Result := Copy(Filename, 1, I); end; function PathExtractDrive(const Filename: String): String; { Returns the drive portion of Filename (either 'x:' or '\\server\share'), or an empty string if there is no drive portion. } var L: Integer; begin L := PathDrivePartLength(Filename); if L = 0 then Result := '' else Result := Copy(Filename, 1, L); end; function PathExtractExt(const Filename: String): String; { Returns the extension portion of the last component of Filename (e.g. '.txt') or an empty string if there is no extension. } var I: Integer; begin I := PathExtensionPos(Filename); if I = 0 then Result := '' else Result := Copy(Filename, I, Maxint); end; function PathExtractName(const Filename: String): String; { Returns the filename portion of Filename (e.g. 'filename.txt'). If Filename ends in a slash or consists only of a drive part or is empty, the result will be an empty string. This function is essentially the opposite of PathExtractPath. } var I: Integer; begin I := PathPathPartLength(Filename, True); Result := Copy(Filename, I + 1, Maxint); end; function PathExtractPath(const Filename: String): String; { Returns the path portion of Filename (e.g. 'c:\dir\'). If Filename contains no drive part or slash, the result will be an empty string. This function is essentially the opposite of PathExtractName. } var I: Integer; begin I := PathPathPartLength(Filename, True); Result := Copy(Filename, 1, I); end; function PathHasInvalidCharacters(const S: String; const AllowDriveLetterColon: Boolean): Boolean; { Checks the specified path for characters that are never allowed in paths, or characters and path components that are accepted by the system but might present a security problem (such as '..' and sometimes ':'). Specifically, True is returned if S includes any of the following: - Control characters (0-31) - One of these characters: /*?"<>| (This means forward slashes and the prefixes '\\?\' and '\??\' are never allowed.) - Colons (':'), except when AllowDriveLetterColon=True and the string's first character is a letter and the second character is the only colon. (This blocks NTFS alternate data stream names.) - A component with a trailing dot or space Due to the last rule above, '.' and '..' components are never allowed, nor are components like these: 'file ' 'file.' 'file. . .' 'file . . ' When expanding paths (with no '\\?\' prefix used), Windows 11 23H2 silently removes all trailing dots and spaces from the end of the string. Therefore, if used at the end of a path, all of the above cases yield just 'file'. On preceding components of the path, nothing is done with spaces; if there is exactly one dot at the end, it is removed (e.g., 'dir.\file' becomes 'dir\file'), while multiple dots are left untouched ('dir..\file' doesn't change). By rejecting trailing dots and spaces up front, we avoid all that weirdness and the problems that could arise from it. Since ':' is considered invalid (except in the one case noted above), it's not possible to sneak in disallowed dots/spaces by including an NTFS alternate data stream name. The function will return True in these cases: '..:streamname' 'file :streamname' } begin Result := True; for var I := Low(S) to High(S) do begin var C := S[I]; if Ord(C) < 32 then Exit; case C of #32, '.': begin if (I = High(S)) or PathCharIsSlash(S[I+1]) then Exit; end; ':': begin { The A-Z check ensures that '.:streamname', ' :streamname', and '\:streamname' are disallowed. } if not AllowDriveLetterColon or (I <> Low(S)+1) or not CharInSet(S[Low(S)], ['A'..'Z', 'a'..'z']) then Exit; end; '/', '*', '?', '"', '<', '>', '|': Exit; end; end; Result := False; end; function PathLastChar(const S: String): PChar; { Returns pointer to last character in the string. Returns nil if the string is empty. } begin if S = '' then Result := nil else Result := @S[High(S)]; end; function PathLastDelimiter(const Delimiters, S: string): Integer; { Returns the index of the last occurrence in S of one of the characters in Delimiters, or 0 if none were found. Note: S is allowed to contain null characters. } var P, E: PChar; begin Result := 0; if (S = '') or (Delimiters = '') then Exit; P := Pointer(S); E := @P[Length(S)]; while P < E do begin if P^ <> #0 then begin if StrScan(PChar(Pointer(Delimiters)), P^) <> nil then Result := (P - PChar(Pointer(S))) + 1; P := PathStrNextChar(P); end else Inc(P); end; end; function PathLowercase(const S: String): String; { Converts the specified path name to lowercase } begin Result := AnsiLowerCase(S); end; function PathPos(Ch: Char; const S: String): Integer; var Len, I: Integer; begin Len := Length(S); I := 1; while I <= Len do begin if S[I] = Ch then begin Result := I; Exit; end; Inc(I, PathCharLength(S, I)); end; Result := 0; end; function PathNormalizeSlashes(const S: String): String; { Returns S minus any superfluous slashes, and with any forward slashes converted to backslashes. For example, if S is 'C:\\\some//path', it returns 'C:\some\path'. Does not remove a double backslash at the beginning of the string, since that signifies a UNC path. } var Len, I: Integer; begin Result := S; Len := Length(Result); I := 1; while I <= Len do begin if Result[I] = '/' then Result[I] := '\'; Inc(I, PathCharLength(Result, I)); end; I := 1; while I < Length(Result) do begin if (Result[I] = '\') and (Result[I+1] = '\') and (I > 1) then Delete(Result, I+1, 1) else Inc(I, PathCharLength(Result, I)); end; end; function PathSame(const S1, S2: String): Boolean; { Returns True if the specified strings (typically filenames) are equal, using a case-insensitive ordinal comparison. Like PathCompare, but faster for checking equality as it returns False immediately if the strings are different lengths. } begin Result := (Length(S1) = Length(S2)) and (PathCompare(S1, S2) = 0); end; function PathStartsWith(const S, AStartsWith: String): Boolean; { Returns True if S starts with (or is equal to) AStartsWith. Uses path casing rules. } var AStartsWithLen: Integer; begin AStartsWithLen := Length(AStartsWith); if Length(S) = AStartsWithLen then Result := (PathCompare(S, AStartsWith) = 0) else if (Length(S) > AStartsWithLen) and not PathCharIsTrailByte(S, AStartsWithLen+1) then Result := (PathCompare(Copy(S, 1, AStartsWithLen), AStartsWith) = 0) else Result := False; end; function PathStrNextChar(const S: PChar): PChar; { Returns pointer to the character after S, unless S points to a null (#0). } begin Result := S; if Result^ <> #0 then Inc(Result); end; function PathStrPrevChar(const Start, Current: PChar): PChar; { Returns pointer to the character before Current, unless Current = Start. } begin Result := Current; if Result > Start then Dec(Result); end; function PathStrScan(const S: PChar; const C: Char): PChar; { Returns pointer to first occurrence of C in S, or nil if there are no occurrences. As with StrScan, specifying #0 for the search character is legal. } begin Result := S; while Result^ <> C do begin if Result^ = #0 then begin Result := nil; Break; end; Result := PathStrNextChar(Result); end; end; function RemoveBackslash(const S: String): String; { Returns S minus any trailing slashes. Use of this function is discouraged; use RemoveBackslashUnlessRoot instead when working with file system paths. } var I: Integer; begin I := Length(S); while (I > 0) and PathCharIsSlash(PathStrPrevChar(Pointer(S), @S[I+1])^) do Dec(I); if I = Length(S) then Result := S else Result := Copy(S, 1, I); end; function RemoveBackslashUnlessRoot(const S: String): String; { Returns S minus any trailing slashes, unless S specifies the root directory of a drive (i.e. 'C:\' or '\'), in which case it leaves 1 slash. } var DrivePartLen, I: Integer; begin DrivePartLen := PathDrivePartLengthEx(S, True); I := Length(S); while (I > DrivePartLen) and PathCharIsSlash(PathStrPrevChar(Pointer(S), @S[I+1])^) do Dec(I); if I = Length(S) then Result := S else Result := Copy(S, 1, I); end; function ValidateAndCombinePath(const ADestDir, AFilename: String; out AResultingPath: String): Boolean; { Combines ADestDir and AFilename without allowing a result outside of ADestDir and without allowing other security problems. Returns True if all security checks pass, with the combination of ADestDir and AFilename in AResultingPath. ADestDir is assumed to be normalized already and have a trailing backslash. AFilename may be a file or directory name. } begin { - Don't allow empty names - Don't allow forward slashes or repeated slashes - Don't allow rooted (non-relative to current directory) names - Don't allow trailing slash - Don't allow invalid characters/dots/spaces (this catches '..') } Result := False; if (AFilename <> '') and (AFilename = PathNormalizeSlashes(AFilename)) and not PathIsRooted(AFilename) and not PathCharIsSlash(AFilename[High(AFilename)]) and not PathHasInvalidCharacters(AFilename, False) then begin { Our validity checks passed. Now pass the combined path to PathExpand (GetFullPathName) to see if it thinks the path needs normalization. If the returned path isn't exactly what was passed in, then consider the name invalid. One way that can happen is if the path ends in an MS-DOS device name: PathExpand('c:\path\NUL') returns '\\.\NUL'. Obviously we don't want devices being opened, so that must be rejected. } var CombinedPath := ADestDir + AFilename; var TestExpandedPath: String; if PathExpand(CombinedPath, TestExpandedPath) and (CombinedPath = TestExpandedPath) then begin AResultingPath := CombinedPath; Result := True; end; end; end; function ValidateAndCombinePath(const ADestDir, AFilename: String): Boolean; begin var ResultingPath: String; Result := ValidateAndCombinePath(ADestDir, AFilename, ResultingPath); end; end.