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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup mdexp
uses: mbarbin/mdexp-actions/setup-mdexp@42da13e622de9559da363ef5906ffde63a982efd # v1.0.0-alpha.1
with:
mdexp-version: 0.0.20260315
mdexp-digest: sha256:f4fc53bcaa50c9dd979b968804c38322b9b7e6aa699d9d6d2d1f101965332018

- name: Environment setup
run: |
echo "DUNE_WORKSPACE=$PWD/dune-workspace-${{ matrix.ocaml-version }}" >> "$GITHUB_ENV"
Expand Down
40 changes: 32 additions & 8 deletions doc/explanation/dune
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
(env
(_
(env-vars
(OCAMLRUNPARAM b=0))))
(library
(name path_normalization)
(package fpath-base-dev)
(inline_tests)
(flags
:standard
-w
+a-4-40-41-42-44-45-48-66
-warn-error
+a
-open
Fpath_sexp0)
(libraries fpath fpath_sexp0)
(instrumentation
(backend bisect_ppx))
(lint
(pps ppx_js_style -allow-let-operators -check-doc-comments))
(preprocess
(pps ppx_expect)))

(rule
(target path-normalization.md.gen)
(package fpath-base-dev)
(deps path_normalization.ml)
(action
(with-stdout-to
%{target}
(run mdexp pp %{deps}))))

(mdx
(rule
(package fpath-base-dev)
(deps
(package fpath-sexp0))
(preludes prelude.txt))
(alias runtest)
(action
(diff path-normalization.md path-normalization.md.gen)))
79 changes: 45 additions & 34 deletions doc/explanation/path-normalization.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,75 @@

## Overview

Starting in version 0.4.0, `Relative_path.t` rejects paths that escape above their starting point.
Starting in version 0.4.0, `Relative_path.t` rejects paths that escape
above their starting point.

**The process:**
1. Paths are normalized using `Fpath.normalize` (resolves `.` and `..` segments)
2. If the normalized path has leading `..` segments, it's rejected by `Relative_path.t`
1. Paths are normalized using `Fpath.normalize` (resolves `.` and `..`
segments)
2. If the normalized path has leading `..` segments, it's rejected by
`Relative_path.t`

## What Gets Rejected

```ocaml
let test s =
match Relative_path.of_string s with
| Ok p -> Printf.printf "%s => Ok %s\n" s (Relative_path.to_string p)
| Error (`Msg m) -> Printf.printf "%s => Error: %s\n" s m
;;
```

Paths that escape above their starting point:

```ocaml
# Relative_path.v ".." ;;
Exception:
Invalid_argument "Relative_path.v: path \"..\" escapes above starting point".
# Relative_path.v "../config" ;;
Exception:
Invalid_argument
"Relative_path.v: path \"../config\" escapes above starting point".
# Relative_path.v "a/../.." ;;
Exception:
Invalid_argument
"Relative_path.v: path \"a/../..\" escapes above starting point".
let%expect_test "escaping paths are rejected" =
test "..";
[%expect {| .. => Error: path ".." escapes above starting point |}];
test "../config";
[%expect {| ../config => Error: path "../config" escapes above starting point |}];
test "a/../..";
[%expect {| a/../.. => Error: path "a/../.." escapes above starting point |}]
;;
```

Paths that stay within bounds are accepted:
Paths that stay within bounds are accepted and normalized:

```ocaml
# Relative_path.to_string (Relative_path.v "a/..") ;;
- : string = "./"
# Relative_path.to_string (Relative_path.v "a/b/../c") ;;
- : string = "a/c"
let%expect_test "non-escaping paths are normalized" =
test "a/..";
[%expect {| a/.. => Ok ./ |}];
test "a/b/../c";
[%expect {| a/b/../c => Ok a/c |}]
;;
```

## Why This Matters

### Prevents Memory Growth

Before v0.4.0, calling `parent` repeatedly could grow memory unboundedly:

<!-- $MDX skip -->
```ocaml
(* Before: Starting from "./" *)
parent "./" (* -> "../" *)
parent "../" (* -> "../../" *)
parent "../../" (* -> "../../../" ... forever *)
```
Before v0.4.0, calling `parent` repeatedly could grow memory unboundedly.
Starting from `"./"`:
- `parent "./"` returned `"../"`
- `parent "../"` returned `"../../"`
- `parent "../../"` returned `"../../../"` — and so on forever

After v0.4.0:
After v0.4.0, `parent` returns `None` when there's nothing left:

```ocaml
# Relative_path.parent Relative_path.empty ;;
- : Relative_path.t option = None
let%expect_test "parent stops at root" =
print_string
(match Relative_path.parent Relative_path.empty with
| None -> "None"
| Some _ -> assert false);
[%expect {| None |}]
;;
```

### Type Safety Guarantee

`Relative_path.t` now guarantees the path won't escape above its starting point, making it safe for:
`Relative_path.t` now guarantees the path won't escape above its starting
point, making it safe for:
- Sandbox operations (can't escape sandbox root)
- Archive extraction (can't write outside target directory)
- Path concatenation (stays within base directory)
Expand All @@ -68,8 +80,7 @@ After v0.4.0:
If you need paths with leading `..` segments, use `Fpath.t` directly:

```ocaml
# let path : Fpath.t = Fpath.v "../config" |> Fpath.normalize ;;
val path : Fpath.t = <abstr>
let path : Fpath.t = Fpath.v "../config" |> Fpath.normalize
```

## Type Selection Guide
Expand Down
106 changes: 106 additions & 0 deletions doc/explanation/path_normalization.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
(*********************************************************************************)
(* fpath-base: Extending [Fpath] to use alongside [Sexplib0] and/or [Base] *)
(* SPDX-FileCopyrightText: 2023-2025 Mathieu Barbin <mathieu.barbin@gmail.com> *)
(* SPDX-License-Identifier: MIT *)
(*********************************************************************************)

(* @mdexp

# Path Normalization and Escaping Prevention

## Overview

Starting in version 0.4.0, `Relative_path.t` rejects paths that escape
above their starting point.

**The process:**
1. Paths are normalized using `Fpath.normalize` (resolves `.` and `..`
segments)
2. If the normalized path has leading `..` segments, it's rejected by
`Relative_path.t`

## What Gets Rejected

@mdexp.code *)
let test s =
match Relative_path.of_string s with
| Ok p -> Printf.printf "%s => Ok %s\n" s (Relative_path.to_string p)
| Error (`Msg m) -> Printf.printf "%s => Error: %s\n" s m
;;

(* @mdexp

Paths that escape above their starting point:

@mdexp.code *)
let%expect_test "escaping paths are rejected" =
test "..";
[%expect {| .. => Error: path ".." escapes above starting point |}];
test "../config";
[%expect {| ../config => Error: path "../config" escapes above starting point |}];
test "a/../..";
[%expect {| a/../.. => Error: path "a/../.." escapes above starting point |}]
;;

(* @mdexp

Paths that stay within bounds are accepted and normalized:

@mdexp.code *)
let%expect_test "non-escaping paths are normalized" =
test "a/..";
[%expect {| a/.. => Ok ./ |}];
test "a/b/../c";
[%expect {| a/b/../c => Ok a/c |}]
;;

(* @mdexp

## Why This Matters

### Prevents Memory Growth

Before v0.4.0, calling `parent` repeatedly could grow memory unboundedly.
Starting from `"./"`:
- `parent "./"` returned `"../"`
- `parent "../"` returned `"../../"`
- `parent "../../"` returned `"../../../"` — and so on forever

After v0.4.0, `parent` returns `None` when there's nothing left:

@mdexp.code *)
let%expect_test "parent stops at root" =
print_string
(match Relative_path.parent Relative_path.empty with
| None -> "None"
| Some _ -> assert false);
[%expect {| None |}]
;;

(* @mdexp

### Type Safety Guarantee

`Relative_path.t` now guarantees the path won't escape above its starting
point, making it safe for:
- Sandbox operations (can't escape sandbox root)
- Archive extraction (can't write outside target directory)
- Path concatenation (stays within base directory)

## When You Need Escaping Paths

If you need paths with leading `..` segments, use `Fpath.t` directly:

```ocaml
let path : Fpath.t = Fpath.v "../config" |> Fpath.normalize
```

## Type Selection Guide

```
Does the path escape above its starting point (has leading ".." after normalization)?
├─ YES → Use Fpath.t
└─ NO → Does it start from filesystem root?
├─ YES → Use Absolute_path.t
└─ NO → Use Relative_path.t
``` *)
5 changes: 5 additions & 0 deletions doc/explanation/path_normalization.mli
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
(*_********************************************************************************)
(*_ fpath-base: Extending [Fpath] to use alongside [Sexplib0] and/or [Base] *)
(*_ SPDX-FileCopyrightText: 2023-2025 Mathieu Barbin <mathieu.barbin@gmail.com> *)
(*_ SPDX-License-Identifier: MIT *)
(*_********************************************************************************)
2 changes: 0 additions & 2 deletions doc/explanation/prelude.txt

This file was deleted.

40 changes: 32 additions & 8 deletions doc/guides/dune
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
(env
(_
(env-vars
(OCAMLRUNPARAM b=0))))
(library
(name migration_0_4_0)
(package fpath-base-dev)
(inline_tests)
(flags
:standard
-w
+a-4-40-41-42-44-45-48-66
-warn-error
+a
-open
Fpath_sexp0)
(libraries fpath fpath_sexp0)
(instrumentation
(backend bisect_ppx))
(lint
(pps ppx_js_style -allow-let-operators -check-doc-comments))
(preprocess
(pps ppx_expect)))

(rule
(target migration-0.4.0.md.gen)
(package fpath-base-dev)
(deps migration_0_4_0.ml)
(action
(with-stdout-to
%{target}
(run mdexp pp %{deps}))))

(mdx
(rule
(package fpath-base-dev)
(deps
(package fpath-sexp0))
(preludes prelude.txt))
(alias runtest)
(action
(diff migration-0.4.0.md migration-0.4.0.md.gen)))
Loading