Skip to content

Commit d4d567e

Browse files
authored
Merge pull request #20 from mbarbin/fix-escaping-paths
Fix escaping paths
2 parents d710b5a + c31bc5f commit d4d567e

File tree

16 files changed

+751
-122
lines changed

16 files changed

+751
-122
lines changed

CHANGES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 0.4.0 (unreleased)
2+
3+
### Added
4+
5+
- Document handling of escaping relative paths (#PR, @mbarbin).
6+
7+
### Changed
8+
9+
- `Relative_path.t` now rejects paths that escape above their starting point (#PR, @mbarbin).
10+
111
## 0.3.1 (2025-05-26)
212

313
### Changed

doc/explanation/dune

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
(env
2+
(_
3+
(env-vars
4+
(OCAMLRUNPARAM b=0))))
5+
6+
(mdx
7+
(package fpath-base-dev)
8+
(deps
9+
(package fpath-base))
10+
(preludes prelude.txt))
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Path Normalization and Escaping Prevention
2+
3+
## Overview
4+
5+
Starting in version 0.4.0, `Relative_path.t` rejects paths that escape above their starting point.
6+
7+
**The process:**
8+
1. Paths are normalized using `Fpath.normalize` (resolves `.` and `..` segments)
9+
2. If the normalized path has leading `..` segments, it's rejected by `Relative_path.t`
10+
11+
## What Gets Rejected
12+
13+
Paths that escape above their starting point:
14+
15+
```ocaml
16+
# Relative_path.v ".." ;;
17+
Exception:
18+
Invalid_argument "Relative_path.v: path \"..\" escapes above starting point".
19+
# Relative_path.v "../config" ;;
20+
Exception:
21+
Invalid_argument
22+
"Relative_path.v: path \"../config\" escapes above starting point".
23+
# Relative_path.v "a/../.." ;;
24+
Exception:
25+
Invalid_argument
26+
"Relative_path.v: path \"a/../..\" escapes above starting point".
27+
```
28+
29+
Paths that stay within bounds are accepted:
30+
31+
```ocaml
32+
# Relative_path.to_string (Relative_path.v "a/..") ;;
33+
- : string = "./"
34+
# Relative_path.to_string (Relative_path.v "a/b/../c") ;;
35+
- : string = "a/c"
36+
```
37+
38+
## Why This Matters
39+
40+
### Prevents Memory Growth
41+
42+
Before v0.4.0, calling `parent` repeatedly could grow memory unboundedly:
43+
44+
<!-- $MDX skip -->
45+
```ocaml
46+
(* Before: Starting from "./" *)
47+
parent "./" (* -> "../" *)
48+
parent "../" (* -> "../../" *)
49+
parent "../../" (* -> "../../../" ... forever *)
50+
```
51+
52+
After v0.4.0:
53+
54+
```ocaml
55+
# Relative_path.parent Relative_path.empty ;;
56+
- : Relative_path.t option = None
57+
```
58+
59+
### Type Safety Guarantee
60+
61+
`Relative_path.t` now guarantees the path won't escape above its starting point, making it safe for:
62+
- Sandbox operations (can't escape sandbox root)
63+
- Archive extraction (can't write outside target directory)
64+
- Path concatenation (stays within base directory)
65+
66+
## When You Need Escaping Paths
67+
68+
If you need paths with leading `..` segments, use `Fpath.t` directly:
69+
70+
```ocaml
71+
# let path : Fpath.t = Fpath.v "../config" |> Fpath.normalize ;;
72+
val path : Fpath.t = <abstr>
73+
```
74+
75+
## Type Selection Guide
76+
77+
```
78+
Does the path escape above its starting point (has leading ".." after normalization)?
79+
├─ YES → Use Fpath.t
80+
└─ NO → Does it start from filesystem root?
81+
├─ YES → Use Absolute_path.t
82+
└─ NO → Use Relative_path.t
83+
```

doc/explanation/prelude.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#require "fpath-base" ;;
2+
open Fpath_base ;;

doc/guides/dune

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
(env
2+
(_
3+
(env-vars
4+
(OCAMLRUNPARAM b=0))))
5+
6+
(mdx
7+
(package fpath-base-dev)
8+
(deps
9+
(package fpath-base))
10+
(preludes prelude.txt))

doc/guides/migration-0.4.0.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Migration Guide: Version 0.4.0
2+
3+
## Summary
4+
5+
Version 0.4.0 makes `Relative_path.t` reject paths that escape above their starting point (paths with leading `..` segments after normalization).
6+
7+
## Breaking Changes
8+
9+
### Construction Functions
10+
11+
All construction functions (`v`, `of_string`, `of_fpath`) now reject escaping paths:
12+
13+
```ocaml
14+
# Relative_path.v "../config" ;;
15+
Exception:
16+
Invalid_argument
17+
"Relative_path.v: path \"../config\" escapes above starting point".
18+
```
19+
20+
**Migration options:**
21+
22+
Use `Absolute_path.t` for explicit paths:
23+
```ocaml
24+
# let path = Absolute_path.v "/path/to/parent/config" ;;
25+
val path : Absolute_path.t = <abstr>
26+
```
27+
28+
Or use `Fpath.t` for paths that may escape:
29+
```ocaml
30+
# let path : Fpath.t = Fpath.v "../config" |> Fpath.normalize ;;
31+
val path : Fpath.t = <abstr>
32+
```
33+
34+
### Parent Function
35+
36+
Returns `None` for the empty path (previously returned `"../"`):
37+
38+
```ocaml
39+
# Relative_path.parent Relative_path.empty ;;
40+
- : Relative_path.t option = None
41+
```
42+
43+
This fixes infinite loops in upward navigation:
44+
45+
```ocaml
46+
# let rec navigate_to_root path =
47+
match Relative_path.parent path with
48+
| None -> path
49+
| Some p -> navigate_to_root p ;;
50+
val navigate_to_root : Relative_path.t -> Relative_path.t = <fun>
51+
# Relative_path.to_string (navigate_to_root (Relative_path.v "a/b/c")) ;;
52+
- : string = "./"
53+
```
54+
55+
### Extend Function
56+
57+
Raises `Invalid_argument` if extending creates an escaping path:
58+
59+
```ocaml
60+
# Relative_path.extend Relative_path.empty (Fsegment.v "..") ;;
61+
Exception:
62+
Invalid_argument
63+
"Relative_path.extend: path \"./..\" escapes above starting point".
64+
```
65+
66+
**Migration:** Use `Fpath.t` if segments might create escaping paths.
67+
68+
### Chop Prefix/Suffix
69+
70+
Empty prefix/suffix now returns `Some path` (previously `None`):
71+
72+
```ocaml
73+
# match Relative_path.chop_prefix (Relative_path.v "foo/bar") ~prefix:Relative_path.empty with
74+
| None -> "no match"
75+
| Some p -> Relative_path.to_string p ;;
76+
- : string = "foo/bar"
77+
```
78+
79+
## Common Migration Patterns
80+
81+
### Dynamic Path Construction
82+
83+
Validate paths and handle rejections:
84+
85+
```ocaml
86+
# let load_relative_file filename =
87+
match Relative_path.of_string filename with
88+
| Error (`Msg err) -> Error err
89+
| Ok path -> Ok path ;;
90+
val load_relative_file : string -> (Relative_path.t, string) result = <fun>
91+
# load_relative_file "config/settings.conf" ;;
92+
- : (Relative_path.t, string) result = Ok <abstr>
93+
```
94+
95+
### Upward Navigation
96+
97+
Use absolute paths for upward traversal:
98+
99+
```ocaml
100+
# let find_project_root has_marker current_path =
101+
let rec search path =
102+
if has_marker path then Some path
103+
else
104+
match Absolute_path.parent path with
105+
| None -> None
106+
| Some parent -> search parent
107+
in
108+
search current_path ;;
109+
val find_project_root :
110+
(Absolute_path.t -> bool) -> Absolute_path.t -> Absolute_path.t option =
111+
<fun>
112+
```
113+
114+
## Why These Changes
115+
116+
1. **Improve type safety** - `Relative_path.t` guarantees non-escaping
117+
2. **Less error-prone APIs** for sandbox operations and recursive parent traversal
118+
119+
See [Path Normalization](../explanation/path-normalization.md) for more details.

doc/guides/prelude.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#require "fpath-base" ;;
2+
open Fpath_base ;;

dune-project

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
(documentation "https://mbarbin.github.io/fpath-base/")
1717

18+
(using mdx 0.4)
19+
1820
;; The value for the [implicit_transitive_deps] option is set during the CI
1921
;; depending on the OCaml compiler version.
2022
;;
@@ -120,6 +122,8 @@
120122
(= :version))
121123
(fpath-sexp0
122124
(= :version))
125+
(mdx
126+
(>= 2.4))
123127
(ppx_compare
124128
(>= v0.17))
125129
(ppx_enumerate

fpath-base-dev.opam

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ depends: [
1919
"fpath-base" {= version}
2020
"fpath-base-tests" {= version}
2121
"fpath-sexp0" {= version}
22+
"mdx" {>= "2.4"}
2223
"ppx_compare" {>= "v0.17"}
2324
"ppx_enumerate" {>= "v0.17"}
2425
"ppx_expect" {>= "v0.17"}

0 commit comments

Comments
 (0)