diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96b03a4..9e77c4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,24 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased]
+### Added
+
+- **New `path` setting type for app manifests.** Apps can now declare a
+ setting with `"type": "path"` whose value is an absolute filesystem
+ path on the host; docker-compose bind-mounts it via the usual `.env`
+ substitution (`${MEDIA_PATH}:/media`). Unlocks media/data-heavy apps
+ (Jellyfin, later Paperless/Nextcloud/Immich) where the user points at
+ an existing folder instead of copying everything into a Docker
+ volume. The install form renders path settings as a plain text input
+ with a `/mnt/…` placeholder hint.
+- **Server-side path validation.** Both `install_from()` and
+ `update_env()` refuse values that aren't absolute, don't exist,
+ aren't directories, or resolve (after `Path.resolve()`) into a
+ system-path deny-list (`/`, `/etc`, `/root`, `/boot`, `/proc`,
+ `/sys`, `/dev`, `/bin`, `/sbin`, `/usr/bin`, `/usr/sbin`,
+ `/var/lib/furtka`). Catches `/mnt/../etc`-style traversal too. Error
+ messages surface in the existing install/edit modal error line.
+
## [26.9-alpha] - 2026-04-21
### Fixed
diff --git a/furtka/api.py b/furtka/api.py
index 5ccfb38..851cbda 100644
--- a/furtka/api.py
+++ b/furtka/api.py
@@ -173,7 +173,9 @@ async function openSettingsDialog(name, action) {
modal.form.innerHTML = data.settings.map(s => {
const id = `field-${esc(s.name)}`;
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
- const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' : '';
+ const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current'
+ : s.type === 'path' ? '/mnt/…'
+ : '';
return `
diff --git a/furtka/installer.py b/furtka/installer.py
index 95b1157..a269d1d 100644
--- a/furtka/installer.py
+++ b/furtka/installer.py
@@ -2,7 +2,7 @@ import shutil
from pathlib import Path
from furtka import sources
-from furtka.manifest import ManifestError, load_manifest
+from furtka.manifest import Manifest, ManifestError, load_manifest
from furtka.paths import apps_dir
# Values that an app's .env.example may use as obvious "fill me in" markers.
@@ -11,6 +11,25 @@ from furtka.paths import apps_dir
# default that ends up screenshotted on Hacker News.
PLACEHOLDER_SECRETS: frozenset[str] = frozenset({"changeme"})
+# System paths that must never be accepted as a user-supplied `path`-type
+# setting. The user is root on their own box, so this is about preventing
+# accidental footguns (typing `/etc` when they meant `/mnt/etc`), not
+# defending against an attacker. Matches exact paths and their subtrees
+# after `Path.resolve()` — so `/mnt/../etc` also lands here.
+DENIED_PATH_PREFIXES: tuple[str, ...] = (
+ "/etc",
+ "/root",
+ "/boot",
+ "/proc",
+ "/sys",
+ "/dev",
+ "/bin",
+ "/sbin",
+ "/usr/bin",
+ "/usr/sbin",
+ "/var/lib/furtka",
+)
+
class InstallError(RuntimeError):
pass
@@ -31,6 +50,53 @@ def _placeholder_keys(env_path: Path) -> list[str]:
return bad
+def _is_denied_system_path(resolved: str) -> bool:
+ if resolved == "/":
+ return True
+ for bad in DENIED_PATH_PREFIXES:
+ if resolved == bad or resolved.startswith(bad + "/"):
+ return True
+ return False
+
+
+def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
+ """Validate the filesystem paths named by `path`-type settings.
+
+ Returns one human-readable message per offending setting. Empty values
+ on non-required settings are allowed — the required-field check in the
+ caller already refuses blanks on required fields before write.
+ """
+ if not env_path.exists():
+ return []
+ values = _read_env(env_path)
+ errors: list[str] = []
+ for s in m.settings:
+ if s.type != "path":
+ continue
+ value = values.get(s.name, "")
+ if not value:
+ continue
+ p = Path(value)
+ if not p.is_absolute():
+ errors.append(f"{s.name}={value!r} must be an absolute path (start with /)")
+ continue
+ try:
+ resolved = p.resolve(strict=False)
+ except (OSError, RuntimeError) as e:
+ errors.append(f"{s.name}={value!r} cannot be resolved: {e}")
+ continue
+ if _is_denied_system_path(str(resolved)):
+ errors.append(f"{s.name}={value!r} resolves into a system path and is not allowed")
+ continue
+ if not resolved.exists():
+ errors.append(f"{s.name}={value!r} does not exist on this box")
+ continue
+ if not resolved.is_dir():
+ errors.append(f"{s.name}={value!r} is not a directory")
+ continue
+ return errors
+
+
def _format_env_value(v: str) -> str:
# Quote values that contain whitespace, quotes, or shell metacharacters so
# docker-compose's env substitution reads them back intact. Simple values
@@ -160,6 +226,10 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
f"file and re-run `furtka app install {m.name}`."
)
+ path_errors = _path_setting_errors(m, env)
+ if path_errors:
+ raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
+
return target
@@ -231,6 +301,9 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
bad = _placeholder_keys(env)
if bad:
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
+ path_errors = _path_setting_errors(m, env)
+ if path_errors:
+ raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
return target
diff --git a/furtka/manifest.py b/furtka/manifest.py
index f8f9f07..c70e9f0 100644
--- a/furtka/manifest.py
+++ b/furtka/manifest.py
@@ -13,7 +13,7 @@ REQUIRED_FIELDS = (
"icon",
)
-VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
+VALID_SETTING_TYPES = frozenset({"text", "password", "number", "path"})
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
diff --git a/tests/test_installer.py b/tests/test_installer.py
index fbc0211..a0c14b7 100644
--- a/tests/test_installer.py
+++ b/tests/test_installer.py
@@ -267,3 +267,93 @@ def test_read_env_values_roundtrip(tmp_path, fake_dirs):
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
values = read_env_values(p)
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}
+
+
+# --- path-type settings ------------------------------------------------------
+
+PATH_MANIFEST = dict(
+ VALID_MANIFEST,
+ name="jellyfin",
+ settings=[
+ {
+ "name": "MEDIA_PATH",
+ "label": "Medienordner",
+ "type": "path",
+ "required": True,
+ }
+ ],
+)
+
+OPTIONAL_PATH_MANIFEST = dict(
+ VALID_MANIFEST,
+ name="jellyfin",
+ settings=[
+ {"name": "OPTIONAL_PATH", "label": "Optional", "type": "path", "required": False}
+ ],
+)
+
+
+def test_install_with_valid_path_succeeds(tmp_path, fake_dirs):
+ media = tmp_path / "media"
+ media.mkdir()
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ installer.install_from(src, settings={"MEDIA_PATH": str(media)})
+ target = apps_dir() / "jellyfin"
+ assert f"MEDIA_PATH={media}" in (target / ".env").read_text()
+
+
+def test_install_rejects_nonexistent_path(tmp_path, fake_dirs):
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ with pytest.raises(installer.InstallError, match="does not exist"):
+ installer.install_from(src, settings={"MEDIA_PATH": str(tmp_path / "ghost")})
+
+
+def test_install_rejects_path_that_is_a_file(tmp_path, fake_dirs):
+ f = tmp_path / "not-a-dir"
+ f.write_text("hi")
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ with pytest.raises(installer.InstallError, match="is not a directory"):
+ installer.install_from(src, settings={"MEDIA_PATH": str(f)})
+
+
+def test_install_rejects_relative_path(tmp_path, fake_dirs):
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ with pytest.raises(installer.InstallError, match="absolute path"):
+ installer.install_from(src, settings={"MEDIA_PATH": "media"})
+
+
+def test_install_rejects_system_path(tmp_path, fake_dirs):
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ with pytest.raises(installer.InstallError, match="system path"):
+ installer.install_from(src, settings={"MEDIA_PATH": "/etc"})
+
+
+def test_install_rejects_root_filesystem(tmp_path, fake_dirs):
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ with pytest.raises(installer.InstallError, match="system path"):
+ installer.install_from(src, settings={"MEDIA_PATH": "/"})
+
+
+def test_install_rejects_deny_list_via_traversal(tmp_path, fake_dirs):
+ # /mnt/../etc resolves to /etc — must be caught after Path.resolve().
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ with pytest.raises(installer.InstallError, match="system path"):
+ installer.install_from(src, settings={"MEDIA_PATH": "/mnt/../etc"})
+
+
+def test_install_accepts_empty_optional_path(tmp_path, fake_dirs):
+ src = _write_app_source(tmp_path, "jellyfin", OPTIONAL_PATH_MANIFEST)
+ installer.install_from(src, settings={"OPTIONAL_PATH": ""})
+ target = apps_dir() / "jellyfin"
+ assert (target / ".env").exists()
+
+
+def test_update_env_rejects_invalid_path(tmp_path, fake_dirs):
+ # First install with a valid path.
+ media = tmp_path / "media"
+ media.mkdir()
+ src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
+ installer.install_from(src, settings={"MEDIA_PATH": str(media)})
+ # Then try to update to a bad path.
+ with pytest.raises(installer.InstallError, match="does not exist"):
+ installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")})
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index ef5edc4..f5d0279 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -155,6 +155,27 @@ def test_settings_reject_unknown_type(tmp_path):
load_manifest(path)
+def test_settings_accept_path_type(tmp_path):
+ payload = dict(
+ VALID_MANIFEST,
+ settings=[
+ {
+ "name": "MEDIA_PATH",
+ "label": "Medienordner",
+ "description": "Absoluter Pfad zu deinen Medien",
+ "type": "path",
+ "required": True,
+ }
+ ],
+ )
+ path = _write_app(tmp_path, "fileshare", payload)
+ m = load_manifest(path)
+ assert len(m.settings) == 1
+ assert m.settings[0].name == "MEDIA_PATH"
+ assert m.settings[0].type == "path"
+ assert m.settings[0].required is True
+
+
def test_settings_reject_duplicate_name(tmp_path):
bad = dict(
VALID_MANIFEST,