Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion docs/module-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,31 @@ checks enforce it, and a failure of any is an ordinary `Module not found:
- **Containment.** The final expanded candidate is checked against the package
directory before the extension probe and again after it. Segment validation
rejects what is invalid on its face; this catches whatever any combination
still normalized into.
still normalized into. The pre-probe check compares normalized spellings,
because the candidate is a name that need not exist yet. The post-probe check
is **physical**: by then a real file has been found, and both it and the
package directory are canonicalized — every symbolic link on either path
resolved — before the comparison. A package that ships
`linked/out.js -> ../../../outside.js` normalizes to a path inside itself
while naming a file outside it, and only the physical check refuses that. It
follows the same principle as [ADR 0071](adr/0071-reject-symlinks-in-sandbox-seed-imports.md),
where the sandbox refuses a symlinked seed import rather than trusting where
its name appears to sit.

Canonicalizing the package directory as well as the candidate is what keeps
pnpm-style layouts working. There `node_modules/<pkg>` is itself a link into
a content-addressed store, so resolving only the candidate would place every
file in every pnpm package outside its own root; resolving both moves the
comparison into the store, where a store-internal file passes and a link that
leaves the store still does not.

**Platform support.** Canonicalization uses `realpath(3)` on Linux, macOS and
the BSDs, and `GetFinalPathNameByHandleW` on Windows, which follows both
symbolic links and directory junctions. Where a host cannot canonicalize a
path — the Lakon/WASI lane, whose in-memory filesystem has no links at all,
or a Windows file the process cannot open even for metadata — the normalized
spelling comparison stands on its own and the boundary is lexical for that
resolution.

The legacy no-`exports` path is stricter here than Node, which would let
`new URL(subpath, packageURL)` walk upward. A subpath containing `..` is never
Expand Down
62 changes: 62 additions & 0 deletions source/shared/FileUtils.Test.pas
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
{$I Shared.inc}

uses
{$IFDEF UNIX}BaseUnix,{$ENDIF}
Classes,
SysUtils,

Expand Down Expand Up @@ -32,6 +33,9 @@ TFileUtilsTests = class(TTestSuite)
procedure TestMixedExtensionsAcrossDepths;
procedure TestIsAbsoluteHostPathRootedForms;
procedure TestIsAbsoluteHostPathRelativeForms;
procedure TestCanonicalHostPathIsUnknownForAMissingPath;
procedure TestCanonicalHostPathIsStableForARealFile;
procedure TestCanonicalHostPathFollowsASymlink;
public
procedure SetupTests; override;
procedure BeforeEach; override;
Expand All @@ -58,6 +62,19 @@ procedure TFileUtilsTests.SetupTests;
TestIsAbsoluteHostPathRootedForms);
Test('IsAbsoluteHostPath rejects paths read against a working directory',
TestIsAbsoluteHostPathRelativeForms);
Test('CanonicalHostPath reports unknown for a path that does not exist',
TestCanonicalHostPathIsUnknownForAMissingPath);
Test('CanonicalHostPath is stable for a file that does exist',
TestCanonicalHostPathIsStableForARealFile);
{ Creating a symlink needs an API this build only has on UNIX. }
{$IFDEF UNIX}
Test('CanonicalHostPath resolves a symlink to its target',
TestCanonicalHostPathFollowsASymlink);
{$ELSE}
Skip('CanonicalHostPath resolves a symlink to its target',
TestCanonicalHostPathFollowsASymlink,
'creating a symlink is not available on this platform');
{$ENDIF}
end;

procedure TFileUtilsTests.BeforeEach;
Expand Down Expand Up @@ -380,6 +397,51 @@ procedure TFileUtilsTests.TestIsAbsoluteHostPathRelativeForms;
{$ENDIF}
end;

procedure TFileUtilsTests.TestCanonicalHostPathIsUnknownForAMissingPath;
begin
{ '' is the "cannot answer" signal, not a path. Callers branch on it, so a
name with nothing behind it must never come back as something. }
Expect<string>(CanonicalHostPath('')).ToBe('');
Expect<string>(CanonicalHostPath(FTempDir + PathDelim + 'absent.txt'))
.ToBe('');
end;

procedure TFileUtilsTests.TestCanonicalHostPathIsStableForARealFile;
var
Canonical: string;
begin
CreateTempFile('present.txt');

Canonical := CanonicalHostPath(FTempDir + PathDelim + 'present.txt');

Expect<Boolean>(Canonical <> '').ToBe(True);
{ Canonicalizing an already-canonical path is the identity — the property the
containment comparison relies on when neither side carries a link. }
Expect<string>(CanonicalHostPath(Canonical)).ToBe(Canonical);
Expect<string>(ExtractFileName(Canonical)).ToBe('present.txt');
end;

procedure TFileUtilsTests.TestCanonicalHostPathFollowsASymlink;
var
LinkPath, TargetCanonical: string;
begin
CreateTempDir('inner');
CreateTempFile('inner' + PathDelim + 'target.txt');
LinkPath := FTempDir + PathDelim + 'link.txt';
TargetCanonical := CanonicalHostPath(
FTempDir + PathDelim + 'inner' + PathDelim + 'target.txt');

{$IFDEF UNIX}
Expect<Boolean>(fpSymlink(PAnsiChar(AnsiString('inner' + PathDelim +
'target.txt')), PAnsiChar(AnsiString(LinkPath))) = 0).ToBe(True);
{$ENDIF}

{ The link and its target are two names for one file, and canonicalization is
what collapses them — the whole reason a containment check can be phrased
physically. }
Expect<string>(CanonicalHostPath(LinkPath)).ToBe(TargetCanonical);
end;

begin
Randomize;
TestRunnerProgram.AddSuite(TFileUtilsTests.Create('FileUtils'));
Expand Down
119 changes: 119 additions & 0 deletions source/shared/FileUtils.pas
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface

uses
{$IFDEF UNIX}BaseUnix,{$ENDIF}
{$IFDEF MSWINDOWS}Windows,{$ENDIF}
Classes,
SysUtils;

Expand Down Expand Up @@ -36,6 +37,23 @@ function HostFileExists(const APath: string): Boolean;
point / junction (Windows). Does not follow the link. }
function HostPathIsSymlink(const APath: string): Boolean;

{ APath with every symbolic link along it resolved to the file it physically
names, or '' when the host cannot answer.

ExpandHostFileName only normalizes a *spelling*: it collapses `.` and `..`
and makes the path absolute, but it never touches the filesystem, so a path
that normalizes inside a directory can still resolve outside it through a
symlinked component. This resolves the links, which is what a containment
guarantee has to be phrased in.

'' means "unknown", never "root", and a caller must decide for itself what an
unknown means. It is returned when the path does not exist (POSIX
`realpath` and the Windows handle open both require it to), when the name
cannot be encoded for the host, and on builds with no canonicalization
available — currently the Lakon/WASI lane, whose filesystem is the virtual
one in SandboxVirtualFileSystem and has no symbolic links at all. }
function CanonicalHostPath(const APath: string): string;

{ Read an entire file as strict UTF-8 source text. No BOM stripping or
newline normalization is performed. Invalid UTF-8 raises EConvertError. }
function ReadUTF8FileText(const APath: string): string;
Expand Down Expand Up @@ -114,6 +132,107 @@ function HostPathIsSymlink(const APath: string): Boolean;
end;
{$ENDIF}

{$IF DEFINED(UNIX) AND NOT DEFINED(LAKON)}
{ POSIX.1-2008 realpath(3). The two-argument form is used rather than the
malloc'ing one so no libc `free` has to be bound as well; POSIX requires the
caller's buffer to hold PATH_MAX bytes, which HOST_PATH_MAX_BYTES is (Linux's
value — macOS and the BSDs cap lower). }
function HostRealPath(APath: PAnsiChar; AResolved: PAnsiChar): PAnsiChar;
cdecl; external 'c' name 'realpath';
{$ENDIF}

{$IFDEF MSWINDOWS}
{ FPC 3.2.2's Windows unit stops at the pre-Vista path API, so the one call
that follows reparse points has to be declared here. FILE_NAME_NORMALIZED
($0) plus VOLUME_NAME_DOS ($0) is the drive-letter spelling; the result still
carries a `\\?\` (or `\\?\UNC\`) prefix, which the caller strips. }
function GetFinalPathNameByHandleW(AFile: THandle; APath: PWideChar;
APathLength, AFlags: DWORD): DWORD;
stdcall; external 'kernel32.dll' name 'GetFinalPathNameByHandleW';
{$ENDIF}

function CanonicalHostPath(const APath: string): string;
{$IF DEFINED(UNIX) AND NOT DEFINED(LAKON)}
const
HOST_PATH_MAX_BYTES = 4096;
var
Buffer: array[0..HOST_PATH_MAX_BYTES - 1] of AnsiChar;
PathBytes, ResolvedBytes: TBytes;
ErrorOffset, Length_: Integer;
begin
Result := '';
if APath = '' then
Exit;
if not TryEncodeUTF8NullTerminated(APath, PathBytes, ErrorOffset) then
Exit;
FillChar(Buffer[0], SizeOf(Buffer), 0);
if HostRealPath(PAnsiChar(@PathBytes[0]), @Buffer[0]) = nil then
Exit;
Length_ := 0;
while (Length_ < SizeOf(Buffer)) and (Buffer[Length_] <> #0) do
Inc(Length_);
SetLength(ResolvedBytes, Length_);
if Length_ > 0 then
Move(Buffer[0], ResolvedBytes[0], Length_);
{ A path the host handed back is bytes, and the host does not promise they
are UTF-8. A name this process cannot represent is one it cannot compare
either, so it stays "unknown" rather than becoming a lossy string. }
if not TryDecodeUTF8(ResolvedBytes, Result, ErrorOffset) then
Result := '';
end;
{$ELSE}
{$IFDEF MSWINDOWS}
const
DEVICE_PATH_PREFIX = '\\?\';
DEVICE_UNC_PATH_PREFIX = '\\?\UNC\';
var
Handle: THandle;
Buffer: array of WideChar;
Needed: DWORD;
begin
Result := '';
if APath = '' then
Exit;
{ FILE_FLAG_BACKUP_SEMANTICS is what lets a *directory* be opened at all, and
zero desired access asks only for the metadata this needs — no read rights,
so an unreadable file still canonicalizes. Every share mode is granted so
the probe never blocks whoever else has the file open. }
Handle := CreateFileW(PWideChar(APath), 0,
FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE, nil,
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, 0);
if Handle = INVALID_HANDLE_VALUE then
Exit;
try
Needed := GetFinalPathNameByHandleW(Handle, nil, 0, 0);
if Needed = 0 then
Exit;
{ The probing call reports the length *including* the terminator and the
filling one reports it without, so a buffer of that size always holds the
answer. A second call that asks for more than it fits means the file was
renamed between the two, and an unknown beats a truncated path. }
SetLength(Buffer, Needed + 1);
Needed := GetFinalPathNameByHandleW(Handle, @Buffer[0], Needed, 0);
if (Needed = 0) or (Needed > DWORD(Length(Buffer) - 1)) then
Exit;
SetString(Result, PWideChar(@Buffer[0]), Integer(Needed));
finally
CloseHandle(Handle);
end;
if Copy(Result, 1, Length(DEVICE_UNC_PATH_PREFIX)) =
DEVICE_UNC_PATH_PREFIX then
Result := '\\' + Copy(Result, Length(DEVICE_UNC_PATH_PREFIX) + 1, MaxInt)
else if Copy(Result, 1, Length(DEVICE_PATH_PREFIX)) = DEVICE_PATH_PREFIX then
Result := Copy(Result, Length(DEVICE_PATH_PREFIX) + 1, MaxInt);
end;
{$ELSE}
begin
{ No canonicalization on this lane. Callers fall back to their lexical check;
see the interface comment. }
Result := '';
end;
{$ENDIF}
{$ENDIF}

function MatchesExtension(const AName: string; const AExtensions: array of string): Boolean;
var
Ext: string;
Expand Down
18 changes: 17 additions & 1 deletion source/units/Goccia.Compiler.Statements.pas
Original file line number Diff line number Diff line change
Expand Up @@ -5927,20 +5927,36 @@ procedure CompileStaticFieldInitializerExpression(
ClosedCount, I: Integer;
ThisReg: UInt16;
OldRejectArgumentsInDirectEval: Boolean;
StrictCtx: TGocciaCompilationContext;
begin
OldRejectArgumentsInDirectEval := ACtx.Template.RejectArgumentsInDirectEval;
ACtx.Template.RejectArgumentsInDirectEval := True;
ACtx.Scope.BeginScope;
{ ES2026 §15.7.1: a ClassBody is strict-mode code whatever the enclosing
script's mode is. An instance field initializer gets that for free — it is
compiled into the `<fields>` child template, whose StrictCode defaults to
True — but a static one is emitted straight into the enclosing template, so
under the non-strict compatibility profile it inherited the script's sloppy
flags and an assignment to an undeclared name compiled to a global create
instead of a throw. Both the compiler-wide flag and the context copy are
cleared, the same pair the computed-element-key path above clears. }
StrictCtx := ACtx;
StrictCtx.NonStrictMode := False;
StrictCtx.CompatibilityNonStrictMode := False;
if Assigned(ACtx.SetNonStrictMode) then
ACtx.SetNonStrictMode(False);
try
ThisReg := ACtx.Scope.DeclareLocal(KEYWORD_THIS, False);
EmitInstruction(ACtx, EncodeABC(OP_MOVE, ThisReg, AClassReg, 0));
CompileFieldValueWithInferredName(ACtx, AExpression, ADest,
CompileFieldValueWithInferredName(StrictCtx, AExpression, ADest,
AInferredName);
ACtx.Scope.EndScope(ClosedLocals, ClosedCount);
for I := 0 to ClosedCount - 1 do
EmitInstruction(ACtx,
EncodeABx(OP_CLOSE_UPVALUE, 0, UInt16(ClosedLocals[I])));
finally
if Assigned(ACtx.SetNonStrictMode) then
ACtx.SetNonStrictMode(ACtx.CompatibilityNonStrictMode);
ACtx.Template.RejectArgumentsInDirectEval :=
OldRejectArgumentsInDirectEval;
end;
Expand Down
16 changes: 14 additions & 2 deletions source/units/Goccia.Engine.Realm.Test.pas
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Goccia.Executor,
Goccia.Executor.Bytecode,
Goccia.Executor.Interpreter,
Goccia.GarbageCollector,
Goccia.Realm,
Goccia.Runtime,
Goccia.RuntimeExtensions.URL,
Expand Down Expand Up @@ -499,8 +500,19 @@ procedure TTestEngineRealm.TestNestedEngineRestoresOuterAsyncContextOnDestroy;
try
Key := TGocciaStringLiteralValue.Create('storage-key');
Store := TGocciaStringLiteralValue.Create('outer-store');
OuterContext := DeriveAsyncContext(nil, Key, Store);
SetCurrentAsyncContext(OuterContext);
// Key and Store live only in Pascal locals until the snapshot is
// installed as the current context; the derive itself allocates, so
// they need temp roots across it or a collection makes this test
// nondeterministic.
TGarbageCollector.Instance.AddTempRoot(Key);
TGarbageCollector.Instance.AddTempRoot(Store);
try
OuterContext := DeriveAsyncContext(nil, Key, Store);
SetCurrentAsyncContext(OuterContext);
finally
TGarbageCollector.Instance.RemoveTempRoot(Store);
TGarbageCollector.Instance.RemoveTempRoot(Key);
end;
Expect<Boolean>(CurrentAsyncContext = OuterContext).ToBe(True);

InnerEngine := TGocciaEngine.Create('<inner-async>', InnerSource,
Expand Down
Loading
Loading