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
2 changes: 1 addition & 1 deletion .github/workflows/ci-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
run: |
set -euo pipefail

if [[ "$EVENT_NAME" == "workflow_dispatch" && "$RUN_MAC_INPUT" == "true" ]]; then
if [[ "$EVENT_NAME" == "pull_request" ]] || [[ "$EVENT_NAME" == "workflow_dispatch" && "$RUN_MAC_INPUT" == "true" ]]; then
echo "run-mac-tests=true" >> "$GITHUB_OUTPUT"
else
echo "run-mac-tests=false" >> "$GITHUB_OUTPUT"
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ This is a **major** release representing a deliberate modernization of the Stati
- **`ServeUnknownFileTypes = true`.** Unknown file types are now rejected by default; add explicit MIME mappings through configuration to serve additional types,
- Legacy `Startup` class and associated extension methods. Modern minimal hosting replaces them.

### Fixed

- Symbolic link resolution in `ContentRootValidator` now recursively follows intermediate symlinks instead of stopping at the first target, preventing traversal bypasses when symlinks chain through multiple levels,
- CI workflow now runs macOS tests on pull request events (previously only on manual dispatch) for complete multi-OS coverage in validation gates,
- Test coverage expanded to include chained symbolic link scenarios and root directory edge cases.

### Migration

See the "Migration from 1.4.0 to 2.0.0" section of `README.md` for the full `1.x` → `2.0.0` configuration mapping and behavioural notes.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ COPY ./cdnroot /cdnroot

## CI and container promotion

Pull requests run the Debug/Release build and Linux/Windows test matrices, optionally including macOS. They also build the Dockerfile once on Linux/amd64, generate an SPDX JSON SBOM, save the image with `docker save`, and upload the tarball as an artifact. No registry credentials or push permissions are available to pull-request builds.
Pull requests run the Debug/Release build and Linux, Windows, and macOS test matrices. They also build the Dockerfile once on Linux/amd64, generate an SPDX JSON SBOM, save the image with `docker save`, and upload the tarball as an artifact. No registry credentials or push permissions are available to pull-request builds. Manually dispatched runs keep macOS optional through `run_mac_tests` because of its additional cost and runtime.

The saved image receives two tags:

Expand Down
13 changes: 10 additions & 3 deletions src/Codebelt.Cdn.Origin/Hosting/ContentRootValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,16 @@ private static string ResolveFinalDirectoryPath(DirectoryInfo directory)

string candidate = Path.Combine(ResolveFinalDirectoryPath(parent), directory.Name);

return Directory.Exists(candidate)
? Directory.ResolveLinkTarget(candidate, returnFinalTarget: true)?.FullName ?? candidate
: candidate;
if (!Directory.Exists(candidate))
{
return candidate;
}

FileSystemInfo? linkTarget = Directory.ResolveLinkTarget(candidate, returnFinalTarget: true);

return linkTarget is null
? candidate
: ResolveFinalDirectoryPath(new DirectoryInfo(linkTarget.FullName));
}

private static string EnsureTrailingSeparator(string path)
Expand Down
36 changes: 34 additions & 2 deletions test/Codebelt.Cdn.Origin.Tests/Hosting/ContentRootValidatorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ public void ExposesApplicationFiles_ShouldReturnTrue_WhenPathsAreEqual()
Assert.True(ContentRootValidator.ExposesApplicationFiles(temp.Path, temp.Path));
}

[Fact]
public void ExposesApplicationFiles_ShouldReturnTrue_WhenPathsAreRootDirectories()
{
string root = Path.GetPathRoot(Path.GetTempPath())!;

Assert.True(ContentRootValidator.ExposesApplicationFiles(root, root));
}

[Fact]
public void ExposesApplicationFiles_ShouldReturnFalse_WhenDirectoriesAreSeparate()
{
Expand All @@ -127,12 +135,36 @@ public void ExposesApplicationFiles_ShouldReturnTrue_WhenContentRootIsSymbolicLi
{
Assert.True(ContentRootValidator.ExposesApplicationFiles(contentRoot, application.Path));

var probe = ContentRootValidator.Probe(contentRoot, application.Path);
Assert.Equal(Path.GetFullPath(application.Path), probe.ResolvedPath);
var targetProbe = ContentRootValidator.Probe(application.Path, application.Path);
var contentRootProbe = ContentRootValidator.Probe(contentRoot, application.Path);
Assert.Equal(targetProbe.ResolvedPath, contentRootProbe.ResolvedPath);
}
finally
{
Directory.Delete(contentRoot);
}
}

[Fact]
public void ExposesApplicationFiles_ShouldReturnTrue_WhenSymbolicLinkTargetTraversesSymbolicLink()
{
using var applicationParent = new TempDirectory();
var applicationDirectory = Directory.CreateDirectory(Path.Combine(applicationParent.Path, "app")).FullName;
using var aliasHost = new TempDirectory();
var applicationParentAlias = Path.Combine(aliasHost.Path, "alias");
CreateDirectorySymbolicLinkOrSkip(applicationParentAlias, applicationParent.Path);
using var linkHost = new TempDirectory();
var contentRoot = Path.Combine(linkHost.Path, "content");
CreateDirectorySymbolicLinkOrSkip(contentRoot, Path.Combine(applicationParentAlias, "app"));

try
{
Assert.True(ContentRootValidator.ExposesApplicationFiles(contentRoot, applicationDirectory));
}
finally
{
Directory.Delete(contentRoot);
Directory.Delete(applicationParentAlias);
}
}

Expand Down
Loading