nuttx/Documentation/os/filesystem/chroot.rst
Vinicius May f6ecf80ebb Documentation: brand new layout for NuttX documentation.
The documentation grew one page at a time, so the tree follows the
history of who wrote what and not the shape of NuttX. Scheduling is
spread over three places, a driver page can sit above the subsystem
that owns it, and the front page lists everything at the same level.
That is a lot to face when all you want to know is where the scheduler
lives.

This change files every page under the code it describes. It is a move,
not a rewrite: outside the ten pages named below, every page keeps the
text that is already in master, and no page's text is deleted.

What it does:

* Groups the table of contents into nine chapters.
* Moves the OS subsystems under os/: scheduling, memory, drivers,
  filesystem, networking, IPC, interrupts, libs, time.
* Renames the platform pages to the names the source tree uses, and
  derives their tags from the tree instead of by hand.
* Splits guides/ by subject.
* Adds Documentation/redirects.py, with a rule for every page that left
  its old path, so old URLs keep working. The redirect page also carries
  a link's #anchor across to the new page.

Ten pages have text that is new or rewritten. Nine of them are the
landing page of a chapter, which has to exist for the new structure:

    index                  the front page
    os/index               OS Design
    os/scheduling/index    Scheduling
    os/interrupts/index    Interrupts
    os/ipc/index           IPC
    os/time/index          Time and timers
    about/index            About
    developing/index       Developing NuttX
    ReleaseNotes/index     Release notes

The tenth is os/libs/libbuiltin, the only page here with technical
content: libs/libbuiltin/ had no page at all. Five SVG diagrams come
with these pages, hand-written XML with no editor metadata.

Nothing outside Documentation/ is touched.

How it was checked:

* Sphinx builds with -W: no warnings, and no document left outside a
  toctree.
* A script, offered in the PR, proves the narrow claim this rests on.
  For every page outside the ten named above it erases what a move
  touches -- link target, path, tag line, toctree block, table border --
  from the whole old text and the whole new text, and requires the two
  to be byte for byte identical. It also requires every sentence of a
  deleted page to turn up somewhere, and every page that left its old
  path to have a redirect, from a URL that existed, to where its content
  went. It exits non-zero and names the page if any of that is not true,
  and it tests added pages too, so forgetting to declare one cannot make
  it pass.
* An independent audit checked 133 factual claims on these ten pages
  against the tree, one shell command per claim: 130 confirmed, 1
  refuted and fixed here, 2 not checkable.
* tools/checkpatch.sh is clean over the range.

The diff is large because moving a page changes every link that points
to it. Most of it is pure renames, and board pages that gained one tag
line.

Assisted-by: Claude:claude-opus-5
2026-10-08 01:40:54 +08:00

252 lines
10 KiB
ReStructuredText

.. _chroot:
======
chroot
======
``chroot()`` is a kernel-enforced filesystem jail. When
``CONFIG_FS_CHROOT`` is enabled, each task group may pin a directory as
its root. Absolute path lookup starts there, so the group cannot see
files outside that tree.
Limitations: this is **not** a container. The current implementation
only changes where pathname lookup begins; it does not provide PID,
mount, or network namespaces, and it does not populate the new root
with ``/dev`` or ``/proc``. See `TODO`_ for what each of these would
require.
Design and implementation
==========================
The Kconfig option and the syscall are the easy part; ``chroot()`` on
NuttX has no MMU-backed process isolation to lean on, so the whole
feature has to be built on top of the single, global pseudo-filesystem
inode tree that every task already shares. This section walks through
why that made the implementation harder than it looks, in the order
the pieces had to be worked out.
Where does the jail live?
--------------------------
The first question is what a "jail" even is in a system with one
shared filesystem tree: it cannot be a separate tree, so it has to be
a *starting point* that path lookups are not allowed to walk above.
That starting point needs to be remembered somewhere per-caller, and
it needs to survive ``fork()``-style child creation the same way an
open file table or a working directory does.
NuttX already keeps exactly that kind of shared, inheritable state on
the task group (``struct task_group_s``), not on the individual task,
because every thread in a task group is supposed to see the same
filesystem view. The jail is stored as a single absolute path::
struct task_group_s
{
...
#ifdef CONFIG_FS_CHROOT
FAR char *tg_root; /* Absolute jail path, or NULL */
#endif
};
A path is used instead of a cached inode so a later unmount/remount
at that location is picked up on the next lookup. ``tg_root`` is
``NULL`` when the group has not called ``chroot()``.
``group_inherit_chroot()`` (``sched/group/group_create.c``) copies the
string to child task groups (kernel threads are skipped):
.. code-block:: c
if (rgroup->tg_root == NULL)
{
return OK;
}
group->tg_root = strdup(rgroup->tg_root);
if (group->tg_root == NULL)
{
return -ENOMEM;
}
That is what makes a jail apply to a whole subtree of children, not
just the one task that called ``chroot()``.
How lookups stay inside the jail
---------------------------------
Every absolute path goes through ``inode_search_setup()`` and then
the original walk from ``g_root_inode``:
1. Prepend ``tg_root`` to the incoming path (``/tmp/jail`` + ``/foo``
becomes ``/tmp/jail/foo``).
2. Canonicalize the combined string with ``_inode_canonicalize()``:
drop empty and ``.`` segments and collapse ``..``. The jail prefix
is the floor for that walk, so ``..`` cannot pop above ``tg_root``.
3. ``/../etc`` inside the jail therefore becomes ``/tmp/jail/etc``,
not host ``/etc``.
4. Continue the original inode-tree walk on that host path.
Without a jail the same canonicalize step still runs, so
``chroot(".")`` under a mount (``$PWD/.``) does not pass a leftover
``.`` to the filesystem as ``relpath``.
There is no separate jailed walk, and no extra ``..`` handling inside
the tree traversal.
Why ``chroot()`` does not touch ``PWD``
-----------------------------------------
Relative lookups go through ``inode_search()``, which prepends
``$PWD`` to the path and then calls the exact same absolute-path
logic described above. That raises an obvious question: what happens
to a task's current directory when its whole notion of "root" just
moved?
An earlier version of this change rewrote ``PWD`` inside ``chroot()``
itself to keep it consistent with the new jail. That turned out to be
both the wrong layer and unnecessary:
* ``chroot()`` is a filesystem primitive; ``PWD`` is environ state.
POSIX ``chroot()`` does not touch the current directory either --
the well-known Unix idiom is that the *caller* must ``chdir()``
immediately after ``chroot()``, precisely so that no stale
reference to the old tree is left lying around.
* It is not needed for containment. A stale ``PWD`` used in a
relative lookup after ``chroot()`` is still rewritten by
``inode_search_setup()`` (prepend the jail path, canonicalize, clamp
``..``) before the tree walk. The lookup can fail or land on a
path inside the jail that was not intended, but it cannot resolve
to a node outside the jail.
So ``chroot()`` leaves ``PWD`` alone, and the caller is responsible
for calling ``chdir()`` afterward if a sane current directory inside
the jail is needed -- exactly as the NSH ``chroot`` command already
does with its trailing ``chdir("/")`` (see `NSH`_ below), which sets
``PWD`` correctly via the ordinary ``chdir()`` path, with no special
jail-aware logic required.
Why the privilege gate lives in ``chroot()`` itself
-----------------------------------------------------
The last design question was who is allowed to call ``chroot()`` at
all. When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, the syscall
checks ``tg_euid`` directly and returns ``EPERM`` for anything but
effective UID 0::
#ifdef CONFIG_SCHED_USER_IDENTITY
if (group->tg_euid != 0)
{
set_errno(EPERM);
return ERROR;
}
#endif
This is intentionally the same *class* of check as the credential DAC
checks described in :ref:`user-identity`, and it inherits the same
caveat: on ``CONFIG_BUILD_FLAT``, kernel and application code share
one address space, so this is a userspace-visible gate rather than a
hardware-enforced boundary -- other code in that address space can
write ``tg_euid`` or ``tg_root`` directly. Protected and kernel builds
close that gap by enforcing the check at the syscall boundary, which
untrusted code cannot bypass. Without ``CONFIG_SCHED_USER_IDENTITY``
at all, every task is effectively root, so ``chroot()`` stays
available to everyone and is a pure path-containment mechanism with no
privilege check gating it.
Configuration
=============
Enable ``CONFIG_FS_CHROOT`` in the filesystem configuration. The
syscall is then available from ``unistd.h``.
Semantics
=========
* ``chroot(path)`` resolves ``path`` relative to the caller's current
root (so a nested ``chroot()`` cannot walk back to the host tree).
* ``path`` must name a directory (``ENOTDIR`` otherwise).
* ``chroot("/")`` resolves to the host root and clears the jail
(``tg_root = NULL``). From inside a jail, ``/`` is the jail root, so
it cannot be used to escape.
* The jail is stored on the task group as the absolute path ``tg_root``.
Child tasks inherit it. Kernel threads do not.
* ``chroot()`` does not modify ``PWD`` or any other environ state; the
caller is responsible for calling ``chdir()`` afterward if a
specific current directory inside the jail is needed. See
`Why chroot() does not touch PWD`_.
NSH
===
The NSH ``chroot`` command performs the usual Unix dance::
chdir(newroot);
chroot(".");
chdir("/");
With no extra arguments the current NSH session stays jailed (``pwd``
shows ``/``, ``ls /`` lists the jail tree) because of the trailing
``chdir("/")`` in the sequence above, not because ``chroot()`` itself
touches ``PWD``. An optional command is executed with ``execvp()``
after the jail is in place; NSH closes non-stdio, non-``O_CLOEXEC``
descriptors first (see below).
When ``CONFIG_SCHED_USER_IDENTITY`` is enabled, drop extra privilege
after the jail is in place (for example ``setuid()`` to a non-root
user) so a later ``chroot()`` cannot be used to escape.
Open file descriptors
=====================
File descriptors opened before ``chroot()`` are not retroactively
contained. POSIX allows this; NuttX does not close them. A jailed
task that inherits a host descriptor can read and write that file
without going through pathname lookup, so the jail does not apply.
This is the most common way ``chroot()`` is misused as a security
tool. Do not treat it as a sandbox against a process that already
holds host file descriptors.
The NSH ``chroot <newroot> <command>`` form closes every open
descriptor above stderr that is not already marked ``O_CLOEXEC``
before ``execvp()``. Stdio (fds 0--2) is left intact. The
no-command form leaves the current NSH session jailed with its
existing descriptors, including any that point outside the tree.
TODO
====
The following are deliberately out of scope for this initial
implementation, and are listed with what each would require, since
that scoping was itself a large part of the design work:
* **Populating ``/dev``, ``/proc``, etc. inside the jail.** Nothing
bind-mounts or otherwise recreates these pseudo-filesystems under
the new root, so a jailed task cannot open devices or read process
info unless the jail directory tree already contains them. Adding
this needs either a bind-mount primitive (mount an existing inode
subtree at a second path) or a per-jail selective mount step run at
``chroot()`` time; neither existed in the VFS before this change,
and both are a materially larger change than pathname jailing.
This is the specific gap raised for using ``chroot()`` to sandbox
remote logins (telnet/ssh): without a minimal ``/dev``, a jailed
shell cannot even do much I/O.
* **PID namespaces.** NuttX has one flat, global task/PID table.
Isolating it per jail would mean making scheduler and IPC lookups
(``kill()``, ``/proc``-style listings, signal delivery) aware of a
namespace boundary, which touches the scheduler core, not just the
VFS. This implementation does not attempt that.
* **Mount namespaces.** The mount table (``g_root_inode`` and its
mounted filesystems) is process-global. A jailed task group can be
confined to a subtree of the existing mount table, but it cannot
have a private view where mounts made outside the jail are hidden,
or where the jailed task can mount/unmount without affecting the
rest of the system. That requires per-task-group mount tables.
* **Network namespaces.** Sockets and network interfaces are global
to the OS instance; nothing in this change touches the network
stack.
* **``pivot_root()``.** Swapping the process root while keeping the
old root reachable is not implemented; ``chroot()`` only changes
where lookups begin.
None of these are ruled out architecturally -- they are simply not
part of this change, which is scoped to pathname-lookup containment.