Skip to content

osfs: Replace filepath-securejoin with os.Root#158

Open
pjbgf wants to merge 2 commits intogo-git:mainfrom
pjbgf:goroot
Open

osfs: Replace filepath-securejoin with os.Root#158
pjbgf wants to merge 2 commits intogo-git:mainfrom
pjbgf:goroot

Conversation

@pjbgf
Copy link
Member

@pjbgf pjbgf commented Oct 22, 2025

This change removes the previous on-demand costly evaluation of paths and replaces it with Go's traversal resistent primitive os.Root.

The benchmark tests in /test/ show that in most scenarios this has a positive performance impact. The numbers below are based using FromRoot which enables reuse of an active os.Root across several operations:

                                 │   base.txt    │                pr.txt                 │
                                 │    sec/op     │     sec/op      vs base               │
Compare/osfs.boundOS_open-4        15.78µ ±   4%    13.38µ ±   2%  -15.22% (p=0.002 n=6)
Compare/osfs.boundOS_read-4        88.45µ ±   1%    91.08µ ±   0%   +2.97% (p=0.002 n=6)
Compare/osfs.boundOS_write-4       924.4µ ±  23%    867.2µ ±  18%        ~ (p=0.180 n=6)
Compare/osfs.boundOS_create-4      31.39µ ±  51%    23.10µ ±  72%        ~ (p=0.065 n=6)
Compare/osfs.boundOS_stat-4        9.493µ ±   2%    4.244µ ±   2%  -55.30% (p=0.002 n=6)
Compare/osfs.boundOS_rename-4      68.14µ ±   1%    42.76µ ±   3%  -37.26% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      30.14µ ±   2%    24.31µ ±   3%  -19.34% (p=0.002 n=6)
Compare/osfs.boundOS_mkdirall-4    18.25µ ± 351%    17.74µ ± 303%        ~ (p=0.699 n=6)
Compare/osfs.boundOS_tempfile-4    39.63µ ±   5%    34.35µ ±   2%  -13.31% (p=0.002 n=6)

                                 │     base.txt     │                 pr.txt                  │
                                 │       B/op       │      B/op       vs base                 │
Compare/osfs.boundOS_open-4         1032.0 ±   0%       424.0 ±   0%  -58.91% (p=0.002 n=6)
Compare/osfs.boundOS_read-4          0.000 ±   0%       0.000 ±   0%        ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_write-4        1507.0 ±   3%       261.0 ±   0%  -82.68% (p=0.002 n=6)
Compare/osfs.boundOS_create-4       1536.5 ±   3%       278.0 ±   1%  -81.91% (p=0.002 n=6)
Compare/osfs.boundOS_stat-4         1120.0 ±   0%       240.0 ±   0%  -78.57% (p=0.002 n=6)
Compare/osfs.boundOS_rename-4       4845.0 ±   0%       122.0 ±   1%  -97.48% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      1055.00 ±   0%       63.00 ±   0%  -94.03% (p=0.002 n=6)
Compare/osfs.boundOS_mkdirall-4     2123.5 ±  20%       199.0 ±   8%  -90.63% (p=0.002 n=6)
Compare/osfs.boundOS_tempfile-4      183.0 ±   1%       336.0 ±   0%  +83.61% (p=0.002 n=6)

                                 │    base.txt    │                 pr.txt                 │
                                 │   allocs/op    │  allocs/op    vs base                  │
Compare/osfs.boundOS_open-4        21.000 ±  0%      9.000 ±  0%   -57.14% (p=0.002 n=6)
Compare/osfs.boundOS_read-4         0.000 ±  0%      0.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_write-4       25.000 ±  4%      8.000 ±  0%   -68.00% (p=0.002 n=6)
Compare/osfs.boundOS_create-4      26.000 ±  4%      8.000 ±  0%   -69.23% (p=0.002 n=6)
Compare/osfs.boundOS_stat-4        19.000 ±  0%      3.000 ±  0%   -84.21% (p=0.002 n=6)
Compare/osfs.boundOS_rename-4      68.000 ±  0%      8.000 ±  0%   -88.24% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      19.000 ±  0%      3.000 ±  0%   -84.21% (p=0.002 n=6)
Compare/osfs.boundOS_mkdirall-4    32.500 ± 14%      8.000 ± 12%   -75.38% (p=0.002 n=6)
Compare/osfs.boundOS_tempfile-4     6.000 ±  0%     14.000 ±  0%  +133.33% (p=0.002 n=6)

The default behaviour is for each operation to open and close a os.Root, which increases the cost per operation but still looks largely better than the previous implementation, with the exception of:

                                 │   base.txt    │                pr.txt                 │
                                 │    sec/op     │     sec/op      vs base               │
Compare/osfs.boundOS_open-4        16.48µ ±   1%    20.54µ ±   3%  +24.66% (p=0.002 n=6)
Compare/osfs.boundOS_stat-4        9.405µ ±   3%   12.629µ ±   6%  +34.29% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      30.58µ ±   3%    32.39µ ±   2%   +5.92% (p=0.002 n=6)

                                 │     base.txt     │                  pr.txt                  │
                                 │       B/op       │      B/op       vs base                  │
Compare/osfs.boundOS_tempfile-4      183.5 ±   0%       456.0 ±   0%  +148.50% (p=0.002 n=6)

                                 │    base.txt    │                 pr.txt                 │
                                 │   allocs/op    │  allocs/op    vs base                  │
Compare/osfs.boundOS_tempfile-4     6.000 ±  0%     18.000 ±  0%  +200.00% (p=0.002 n=6)

For a full break-down, refer to the comment below.

A new ErrPathEscapesParent was introduced to represent when an operation is
attempting to escape the root/bound dir used for the bound OS.

Requires Go 1.25.

Fixes #135.
Relates to #101.

@github-actions
Copy link

github-actions bot commented Feb 26, 2026

⚠️ Benchmark Regressions Detected (>5%)

The following benchmarks have degraded by more than 5% in time, memory, or allocations:

Compare/osfs.boundOS_open-4        16.48µ ±   1%    20.54µ ±   3%  +24.66% (p=0.002 n=6)
Compare/osfs.boundOS_stat-4        9.405µ ±   3%   12.629µ ±   6%  +34.29% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      30.58µ ±   3%    32.39µ ±   2%   +5.92% (p=0.002 n=6)
Compare/osfs.chrootOS_remove-4       159.0 ±   0%       172.0 ±   0%    +8.18% (p=0.002 n=6)
Compare/osfs.boundOS_tempfile-4      183.5 ±   0%       456.0 ±   0%  +148.50% (p=0.002 n=6)
Compare/osfs.boundOS_tempfile-4     6.000 ±  0%     18.000 ±  0%  +200.00% (p=0.002 n=6)
Full benchmark comparison (benchstat)
goos: linux
goarch: amd64
pkg: github.com/go-git/go-billy/v6/test
cpu: AMD EPYC 7763 64-Core Processor                
                                 │   base.txt    │                pr.txt                 │
                                 │    sec/op     │     sec/op      vs base               │
Compare/osfs.chrootOS_open-4       11.62µ ±   3%    11.17µ ±   2%   -3.92% (p=0.002 n=6)
Compare/osfs.boundOS_open-4        16.48µ ±   1%    20.54µ ±   3%  +24.66% (p=0.002 n=6)
Compare/memfs_open-4               2.241µ ±   1%    1.991µ ±   2%  -11.16% (p=0.002 n=6)
Compare/osfs.chrootOS_read-4       90.51µ ±   0%    87.11µ ±   1%   -3.75% (p=0.002 n=6)
Compare/osfs.boundOS_read-4        90.64µ ±   2%    90.19µ ±   0%        ~ (p=0.132 n=6)
Compare/memfs_read-4               29.43µ ±   2%    30.29µ ±   1%   +2.93% (p=0.002 n=6)
Compare/osfs.chrootOS_write-4      880.6µ ±  19%    874.6µ ±  19%        ~ (p=0.394 n=6)
Compare/osfs.boundOS_write-4       877.6µ ±  17%    873.6µ ±  18%        ~ (p=0.937 n=6)
Compare/memfs_write-4              394.8µ ±   8%    392.1µ ±  14%        ~ (p=0.394 n=6)
Compare/osfs.chrootOS_create-4     29.16µ ±  59%    30.80µ ±  45%        ~ (p=0.699 n=6)
Compare/osfs.boundOS_create-4      32.01µ ±  55%    31.18µ ±  56%        ~ (p=0.240 n=6)
Compare/memfs_create-4             4.538µ ±  26%    4.068µ ±  27%  -10.36% (p=0.041 n=6)
Compare/osfs.chrootOS_stat-4       5.060µ ±   2%    4.927µ ±   4%   -2.64% (p=0.009 n=6)
Compare/osfs.boundOS_stat-4        9.405µ ±   3%   12.629µ ±   6%  +34.29% (p=0.002 n=6)
Compare/memfs_stat-4               1.575µ ±   1%    1.483µ ±   1%   -5.87% (p=0.002 n=6)
Compare/osfs.chrootOS_rename-4     47.37µ ±   2%    46.61µ ±   2%        ~ (p=0.132 n=6)
Compare/osfs.boundOS_rename-4      68.39µ ±   3%    49.96µ ±   3%  -26.95% (p=0.002 n=6)
Compare/memfs_rename-4             16.97m ±   3%    12.73m ±   3%  -24.99% (p=0.002 n=6)
Compare/osfs.chrootOS_remove-4     25.50µ ±   3%    24.74µ ±   2%   -3.00% (p=0.009 n=6)
Compare/osfs.boundOS_remove-4      30.58µ ±   3%    32.39µ ±   2%   +5.92% (p=0.002 n=6)
Compare/memfs_remove-4             2.837µ ±   1%    2.555µ ±   1%   -9.92% (p=0.002 n=6)
Compare/osfs.chrootOS_mkdirall-4   9.473µ ± 526%    9.306µ ± 579%        ~ (p=0.699 n=6)
Compare/osfs.boundOS_mkdirall-4    19.32µ ± 324%    25.01µ ± 210%        ~ (p=0.310 n=6)
Compare/memfs_mkdirall-4           3.090µ ±  49%    2.753µ ±  47%        ~ (p=0.132 n=6)
Compare/osfs.chrootOS_tempfile-4   39.58µ ±   6%    39.95µ ±   9%        ~ (p=0.818 n=6)
Compare/osfs.boundOS_tempfile-4    40.20µ ±  14%    40.52µ ±   6%        ~ (p=0.937 n=6)
Compare/memfs_tempfile-4           4.073µ ±   3%    3.743µ ±   4%   -8.09% (p=0.002 n=6)
geomean                            29.41µ           28.89µ          -1.77%

                                 │     base.txt     │                  pr.txt                  │
                                 │       B/op       │      B/op       vs base                  │
Compare/osfs.chrootOS_open-4         360.0 ±   0%       360.0 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_open-4         1032.0 ±   0%       544.0 ±   0%   -47.29% (p=0.002 n=6)
Compare/memfs_open-4                 192.0 ±   0%       192.0 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_read-4         0.000 ±   0%       0.000 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_read-4          0.000 ±   0%       0.000 ±   0%         ~ (p=1.000 n=6) ¹
Compare/memfs_read-4                 0.000 ±   0%       0.000 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_write-4        707.0 ±   0%       709.5 ±   0%    +0.35% (p=0.002 n=6)
Compare/osfs.boundOS_write-4        1506.0 ±   3%       381.5 ±   0%   -74.67% (p=0.002 n=6)
Compare/memfs_write-4              5.164Mi ±   0%     5.164Mi ±   0%         ~ (p=0.478 n=6)
Compare/osfs.chrootOS_create-4       717.5 ±   0%       717.5 ±   0%         ~ (p=1.000 n=6)
Compare/osfs.boundOS_create-4       1534.5 ±   3%       397.0 ±   0%   -74.13% (p=0.002 n=6)
Compare/memfs_create-4               346.0 ± 105%       343.0 ± 104%         ~ (p=0.848 n=6)
Compare/osfs.chrootOS_stat-4         336.0 ±   0%       336.0 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_stat-4         1120.0 ±   0%       360.0 ±   0%   -67.86% (p=0.002 n=6)
Compare/memfs_stat-4                 128.0 ±   0%       128.0 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_rename-4     1.171Ki ±   0%     1.171Ki ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_rename-4       4844.5 ±   0%       241.0 ±   0%   -95.03% (p=0.002 n=6)
Compare/memfs_rename-4               176.0 ±   2%       176.0 ±   2%         ~ (p=1.000 n=6)
Compare/osfs.chrootOS_remove-4       159.0 ±   0%       172.0 ±   0%    +8.18% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4       1003.0 ±   0%       183.0 ±   0%   -81.75% (p=0.002 n=6)
Compare/memfs_remove-4               111.0 ±   0%       111.0 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_mkdirall-4     423.5 ±  83%       425.5 ±  86%         ~ (p=1.000 n=6)
Compare/osfs.boundOS_mkdirall-4     2111.0 ±  19%       319.0 ±   5%   -84.89% (p=0.002 n=6)
Compare/memfs_mkdirall-4             188.0 ± 170%       184.0 ± 161%         ~ (p=0.870 n=6)
Compare/osfs.chrootOS_tempfile-4     855.0 ±   0%       855.0 ±   0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_tempfile-4      183.5 ±   0%       456.0 ±   0%  +148.50% (p=0.002 n=6)
Compare/memfs_tempfile-4             464.0 ±   0%       464.0 ±   0%         ~ (p=1.000 n=6) ¹
geomean                                           ²                    -31.27%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                 │    base.txt    │                 pr.txt                 │
                                 │   allocs/op    │  allocs/op    vs base                  │
Compare/osfs.chrootOS_open-4        9.000 ±  0%      9.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_open-4         21.00 ±  0%      13.00 ±  0%   -38.10% (p=0.002 n=6)
Compare/memfs_open-4                10.00 ±  0%      10.00 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_read-4        0.000 ±  0%      0.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_read-4         0.000 ±  0%      0.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/memfs_read-4                0.000 ±  0%      0.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_write-4       13.00 ±  0%      13.00 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_write-4        25.00 ±  4%      12.00 ±  0%   -52.00% (p=0.002 n=6)
Compare/memfs_write-4               25.50 ±  6%      25.00 ±  8%         ~ (p=0.924 n=6)
Compare/osfs.chrootOS_create-4      13.00 ±  0%      13.00 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_create-4       26.00 ±  4%      12.00 ±  0%   -53.85% (p=0.002 n=6)
Compare/memfs_create-4              12.50 ± 20%      12.00 ± 25%         ~ (p=0.924 n=6)
Compare/osfs.chrootOS_stat-4        4.000 ±  0%      4.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_stat-4        19.000 ±  0%      7.000 ±  0%   -63.16% (p=0.002 n=6)
Compare/memfs_stat-4                5.000 ±  0%      5.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_rename-4      14.00 ±  0%      14.00 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_rename-4       68.00 ±  0%      12.00 ±  0%   -82.35% (p=0.002 n=6)
Compare/memfs_rename-4              9.000 ±  0%      9.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_remove-4      4.000 ±  0%      4.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_remove-4      19.000 ±  0%      7.000 ±  0%   -63.16% (p=0.002 n=6)
Compare/memfs_remove-4              5.000 ±  0%      5.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.chrootOS_mkdirall-4    6.000 ± 50%      5.500 ± 64%         ~ (p=0.913 n=6)
Compare/osfs.boundOS_mkdirall-4     32.50 ± 14%      12.00 ±  8%   -63.08% (p=0.002 n=6)
Compare/memfs_mkdirall-4            5.500 ± 45%      5.500 ± 45%         ~ (p=1.000 n=6)
Compare/osfs.chrootOS_tempfile-4    16.00 ±  0%      16.00 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_tempfile-4     6.000 ±  0%     18.000 ±  0%  +200.00% (p=0.002 n=6)
Compare/memfs_tempfile-4            16.00 ±  0%      16.00 ±  0%         ~ (p=1.000 n=6) ¹
geomean                                         ²                  -19.22%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                              │   base.txt    │               pr.txt                │
                              │      B/s      │      B/s       vs base              │
Compare/osfs.chrootOS_read-4    345.3Mi ±  0%   358.7Mi ±  1%  +3.90% (p=0.002 n=6)
Compare/osfs.boundOS_read-4     344.8Mi ±  2%   346.5Mi ±  0%       ~ (p=0.132 n=6)
Compare/memfs_read-4            1.037Gi ±  2%   1.008Gi ±  1%  -2.84% (p=0.002 n=6)
Compare/osfs.chrootOS_write-4   1.109Gi ± 24%   1.117Gi ± 23%       ~ (p=0.394 n=6)
Compare/osfs.boundOS_write-4    1.113Gi ± 21%   1.118Gi ± 22%       ~ (p=0.937 n=6)
Compare/memfs_write-4           2.477Gi ±  8%   2.491Gi ± 13%       ~ (p=0.394 n=6)
geomean                         863.6Mi         868.2Mi        +0.52%

This change removes the previous on-demand costly evaluation of paths
with the Go's traversal resistent primitive os.Root.

The benchmarks in /test/ indicate that in most scenarios this represent
a positive performance:

                                 │   base.txt    │                pr.txt                 │
                                 │    sec/op     │     sec/op      vs base               │
Compare/osfs.boundOS_open-4        15.78µ ±   4%    13.38µ ±   2%  -15.22% (p=0.002 n=6)
Compare/osfs.boundOS_read-4        88.45µ ±   1%    91.08µ ±   0%   +2.97% (p=0.002 n=6)
Compare/osfs.boundOS_write-4       924.4µ ±  23%    867.2µ ±  18%        ~ (p=0.180 n=6)
Compare/osfs.boundOS_create-4      31.39µ ±  51%    23.10µ ±  72%        ~ (p=0.065 n=6)
Compare/osfs.boundOS_stat-4        9.493µ ±   2%    4.244µ ±   2%  -55.30% (p=0.002 n=6)
Compare/osfs.boundOS_rename-4      68.14µ ±   1%    42.76µ ±   3%  -37.26% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      30.14µ ±   2%    24.31µ ±   3%  -19.34% (p=0.002 n=6)
Compare/osfs.boundOS_mkdirall-4    18.25µ ± 351%    17.74µ ± 303%        ~ (p=0.699 n=6)
Compare/osfs.boundOS_tempfile-4    39.63µ ±   5%    34.35µ ±   2%  -13.31% (p=0.002 n=6)

                                 │     base.txt     │                 pr.txt                  │
                                 │       B/op       │      B/op       vs base                 │
Compare/osfs.boundOS_open-4         1032.0 ±   0%       424.0 ±   0%  -58.91% (p=0.002 n=6)
Compare/osfs.boundOS_read-4          0.000 ±   0%       0.000 ±   0%        ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_write-4        1507.0 ±   3%       261.0 ±   0%  -82.68% (p=0.002 n=6)
Compare/osfs.boundOS_create-4       1536.5 ±   3%       278.0 ±   1%  -81.91% (p=0.002 n=6)
Compare/osfs.boundOS_stat-4         1120.0 ±   0%       240.0 ±   0%  -78.57% (p=0.002 n=6)
Compare/osfs.boundOS_rename-4       4845.0 ±   0%       122.0 ±   1%  -97.48% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      1055.00 ±   0%       63.00 ±   0%  -94.03% (p=0.002 n=6)
Compare/osfs.boundOS_mkdirall-4     2123.5 ±  20%       199.0 ±   8%  -90.63% (p=0.002 n=6)
Compare/osfs.boundOS_tempfile-4      183.0 ±   1%       336.0 ±   0%  +83.61% (p=0.002 n=6)

                                 │    base.txt    │                 pr.txt                 │
                                 │   allocs/op    │  allocs/op    vs base                  │
Compare/osfs.boundOS_open-4        21.000 ±  0%      9.000 ±  0%   -57.14% (p=0.002 n=6)
Compare/osfs.boundOS_read-4         0.000 ±  0%      0.000 ±  0%         ~ (p=1.000 n=6) ¹
Compare/osfs.boundOS_write-4       25.000 ±  4%      8.000 ±  0%   -68.00% (p=0.002 n=6)
Compare/osfs.boundOS_create-4      26.000 ±  4%      8.000 ±  0%   -69.23% (p=0.002 n=6)
Compare/osfs.boundOS_stat-4        19.000 ±  0%      3.000 ±  0%   -84.21% (p=0.002 n=6)
Compare/osfs.boundOS_rename-4      68.000 ±  0%      8.000 ±  0%   -88.24% (p=0.002 n=6)
Compare/osfs.boundOS_remove-4      19.000 ±  0%      3.000 ±  0%   -84.21% (p=0.002 n=6)
Compare/osfs.boundOS_mkdirall-4    32.500 ± 14%      8.000 ± 12%   -75.38% (p=0.002 n=6)
Compare/osfs.boundOS_tempfile-4     6.000 ±  0%     14.000 ±  0%  +133.33% (p=0.002 n=6)

A new ErrPathEscapesParent was introduced to represent when an operation is
attempting to escape the root/bound dir used for the bound OS.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
Without ensuring the all required modules are present, they will be downloaded on
demand, which may result in the benchmark file starting with:
go: downloading  <module_name> <version>

This in turn breaks benchstat, which becomes unable to compare the two
benchmark results.

Signed-off-by: Paulo Gomes <pjbgf@linux.com>
@pjbgf pjbgf marked this pull request as ready for review February 26, 2026 22:06
Copilot AI review requested due to automatic review settings February 26, 2026 22:06
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request replaces the third-party filepath-securejoin library with Go 1.25's native os.Root API for path traversal protection in the BoundOS filesystem implementation. The change aims to improve performance through more efficient path validation while leveraging Go's built-in security primitives.

Changes:

  • Replaces filepath-securejoin dependency with os.Root API, requiring Go 1.25.0
  • Introduces ErrPathEscapesParent error and FromRoot function for flexible root management
  • Updates all filesystem operations to use os.Root with consistent error translation
  • Migrates tests from string-based error matching to sentinel error comparison using errors.Is

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
osfs/os_bound.go Core rewrite to use os.Root for all operations; adds FromRoot function, translateError helper, and dual-mode root lifecycle management
osfs/os_bound_test.go Updates test assertions to use errors.Is; adds TestFromRoot; removes TestAbs and TestInsideBaseDirEval; adds new symlink test cases
osfs/os_options.go Moves WithBoundOS and WithChrootOS option functions to separate file; removes WithDeduplicatePath option
osfs/os.go Simplifies New function by removing deduplicatePath option handling
go.mod Bumps Go version to 1.25.0; removes filepath-securejoin dependency
go.sum Removes filepath-securejoin dependency entries
.github/workflows/bench-regression.yml Adds explicit go mod download steps for better caching

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +176 to +185
func (fs *BoundOS) MkdirAll(name string, _ gofs.FileMode) error {
root, cleanup, err := fs.fsRoot()
if err != nil {
return err
}
return os.MkdirAll(dir, perm)
defer cleanup()

// os.Root errors when perm contains bits other than the nine least-significant bits (0o777).
err = root.MkdirAll(name, 0o777)
return translateError(err, name)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MkdirAll method ignores the provided perm parameter and always uses 0o777 (line 184). While this is mentioned in the comment, it represents a behavioral change from standard filesystem semantics where the caller expects to control directory permissions. This could be surprising to users and may cause issues if specific permissions are required. Consider documenting this more prominently in the method's godoc comment, or applying the permission to the final directory in the path after creation if os.Root allows it.

Copilot uses AI. Check for mistakes.
dir, err := fs.abs(path)
f, err := root.OpenFile(name, flag, perm)
if err != nil {
return nil, err
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned from root.OpenFile is not being translated, which means that path escape errors from os.Root won't be wrapped with ErrPathEscapesParent. This is inconsistent with other methods in this file that call translateError on errors returned from root operations. Consider changing line 120 to return nil, translateError(err, name) for consistency.

Suggested change
return nil, err
return nil, translateError(err, name)

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +173
// Ensure the target directory exists.
err = root.MkdirAll(filepath.Dir(to), defaultDirectoryMode)
if err == nil {
err = root.Rename(from, to)
}

return os.Rename(f, t)
return translateError(err, to)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Rename method translates errors using only the to parameter. However, errors can occur from both the from and to paths (e.g., if from escapes the root). While the current implementation may be acceptable if the error message is sufficient, consider whether the error should include information about which path caused the issue. If both paths could cause errors, you may want to handle them separately or include both in the error message.

Copilot uses AI. Check for mistakes.
Comment on lines +198 to +205

_, err = os.Stat(fs.baseDir)
if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(fs.baseDir, defaultDirectoryMode)
if err != nil {
return nil, err
}
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Stat method creates the base directory if it doesn't exist when an absolute path is provided (lines 199-205). This side effect is unexpected for a read operation like Stat. This behavior could lead to unintended directory creation and may not be consistent with standard filesystem semantics where Stat is a read-only operation. Consider whether this auto-creation is intentional and necessary, or if it should be removed.

Suggested change
_, err = os.Stat(fs.baseDir)
if err != nil && os.IsNotExist(err) {
err = os.MkdirAll(fs.baseDir, defaultDirectoryMode)
if err != nil {
return nil, err
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +384 to +386
defer childRoot.Close()

return New(childRoot.Name(), WithBoundOS()), nil
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The childRoot is closed immediately after being opened (line 384), but then childRoot.Name() is used to create a new filesystem (line 386). After the defer statement executes, the childRoot is closed, so the new filesystem created on line 386 will have a base directory pointing to a path from a now-closed os.Root. While childRoot.Name() returns a string path that remains valid after closing, this pattern is confusing and could be error-prone. Consider storing childRoot.Name() in a variable before the defer, or restructure to make the lifecycle clearer.

Suggested change
defer childRoot.Close()
return New(childRoot.Name(), WithBoundOS()), nil
childRootPath := childRoot.Name()
if err := childRoot.Close(); err != nil {
return nil, fmt.Errorf("unable to close child root: %w", err)
}
return New(childRootPath, WithBoundOS()), nil

Copilot uses AI. Check for mistakes.
Comment on lines 384 to 395
dir = os.TempDir()
f, err = fs.TempFile("../../../above/cwd", "prefix")
require.ErrorIs(t, err, ErrPathEscapesParent)
assert.Nil(f)

dir = filepath.Join(dir, "/tmp")
// For windows, volume name must be removed.
if v := filepath.VolumeName(dir); v != "" {
dir = strings.TrimPrefix(dir, v)
}

f, err = fs.TempFile(dir, "prefix")
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dir variable is reassigned to os.TempDir() on line 384, which changes the reference point for subsequent tests. This appears to be a bug because the test cases after this line are checking paths relative to the reassigned dir, which is no longer the bound directory of the filesystem. The filesystem fs was created with the original dir (from line 369), so the later assertions are testing with an unrelated directory path. This looks like it may have been a copy-paste error from previous test code.

Suggested change
dir = os.TempDir()
f, err = fs.TempFile("../../../above/cwd", "prefix")
require.ErrorIs(t, err, ErrPathEscapesParent)
assert.Nil(f)
dir = filepath.Join(dir, "/tmp")
// For windows, volume name must be removed.
if v := filepath.VolumeName(dir); v != "" {
dir = strings.TrimPrefix(dir, v)
}
f, err = fs.TempFile(dir, "prefix")
f, err = fs.TempFile("../../../above/cwd", "prefix")
require.ErrorIs(t, err, ErrPathEscapesParent)
assert.Nil(f)
outsideDir := filepath.Join(os.TempDir(), "/tmp")
// For windows, volume name must be removed.
if v := filepath.VolumeName(outsideDir); v != "" {
outsideDir = strings.TrimPrefix(outsideDir, v)
}
f, err = fs.TempFile(outsideDir, "prefix")

Copilot uses AI. Check for mistakes.
Comment on lines +91 to +110
// When not creating, read symlink links so that they can be made
// relative and therefore work.
if flag&os.O_CREATE == 0 {
fi, err := root.Lstat(name)
if err == nil && fi.Mode()&gofs.ModeSymlink != 0 {
fn, err := root.Readlink(name)
if err != nil {
return nil, err
}
name = fn
}
}

if filepath.IsAbs(name) {
fn, err := filepath.Rel(fs.baseDir, name)
if err != nil {
return nil, err
}
name = fn
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the OpenFile method, when a symlink is detected (lines 94-101), the code reads the symlink target and replaces name with it. However, if the symlink target is an absolute path, the subsequent check on line 104 will try to make it relative to fs.baseDir. This could cause issues if the symlink target is an absolute path that doesn't descend from fs.baseDir, as filepath.Rel may return an error or an unexpected path. Consider handling the case where the symlink target is an absolute path that escapes the base directory more explicitly.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

osfs/os_bound.go:Remove should remove the symlink, not the symlink target

2 participants