Skip to content

Add a fail-closed native-only option for FileLock #603

Description

@mmashwani

Severity: Enhancement (security-sensitive callers cannot prohibit an implicit change from a kernel lock to a file-existence protocol)

Summary

I would like an opt-in native-only policy for FileLock. On Unix, an ENOSYS
from flock() currently warns, unlinks the native lock path, changes the live
object's class to SoftFileLock, and continues acquisition under a different
protocol.

Automatic fallback is useful and should remain the backward-compatible
default. Some callers, however, require a kernel-enforced lock and need
acquisition to fail if that guarantee is unavailable.

Environment

  • package/version: filelock 3.29.7
  • source: commit 1efb8932c08789deb08ad93a91d8996cc36bb9cc
  • platforms: Unix-like systems and filesystems where flock() returns ENOSYS
  • affected APIs: FileLock, AsyncFileLock

Reproduction

  1. Run the attached filelock_enosys_implicit_protocol_change.py with
    filelock==3.29.7.

  2. Observe:

    type_before=UnixFileLock
    type_after=SoftFileLock
    filelock_version=3.29.7
    class_changed=True
    warning_count=1
    warning=flock not supported on this filesystem, falling back to SoftFileLock
    locked=True
    

Observed vs expected

Observed:

FileLock always accepts the weaker fallback after ENOSYS. A caller can observe
only a warning and cannot request fail-closed behavior.

Expected:

An opt-in policy makes FileLock raise when the requested native backend is
unavailable, without changing the existing automatic default.

Root cause (verified in source, 3.29.7)

UnixFileLock._acquire()
handles ENOSYS by unlinking the path, calling _fallback_to_soft_lock(), and
acquiring again.

_fallback_to_soft_lock()
changes self.__class__ after emitting a warning. This automatic behavior was
introduced intentionally in PR #480 to resolve #289 and #349, and it addresses
the same runtime ENOSYS failure mode reported in #67. The missing contract is
an opt-out for callers that cannot accept the fallback.

Suggested fix

Add a keyword-only backend policy with a backward-compatible default, for
example backend_policy="auto" with values such as "auto", "native-only",
and "soft".

For "native-only", propagate a specific unsupported-backend error on
ENOSYS before unlinking the path or changing the object class. Include the
policy in singleton compatibility checks and thread it through async wrappers.

Verification

  • Existing calls retain automatic fallback and the current warning.
  • Native-only calls raise a documented exception on ENOSYS.
  • Native-only failure does not change type(lock) or acquire a soft lock.
  • Singleton construction rejects incompatible backend policies.
  • Sync and async APIs behave consistently.

Current workaround

Instantiate UnixFileLock directly and preflight the filesystem, or convert
the fallback warning into an exception with a process-wide warning filter.
Neither workaround is a direct per-lock contract.

Reproduction script

filelock_enosys_implicit_protocol_change.py
"""Show that UnixFileLock changes lock protocol after ENOSYS with no opt-out."""

from __future__ import annotations

import errno
import sys
import tempfile
import warnings
from pathlib import Path
from unittest.mock import patch

import filelock
from filelock import FileLock
from filelock import _unix as unix_backend


def main() -> int:
    if sys.platform == "win32":
        print("This reproduction targets UnixFileLock.")
        return 2

    with tempfile.TemporaryDirectory() as directory:
        path = Path(directory) / "resource.lock"
        lock = FileLock(path, timeout=0)
        before = type(lock).__name__

        with (
            patch.object(
                unix_backend.fcntl,
                "flock",
                side_effect=OSError(errno.ENOSYS, "injected unsupported flock"),
            ),
            warnings.catch_warnings(record=True) as caught,
        ):
            warnings.simplefilter("always")
            lock.acquire()

        try:
            after = type(lock).__name__
            messages = [str(item.message) for item in caught]
            warning = messages[0] if messages else None
            class_changed = before != after
            print(f"type_before={before}")
            print(f"type_after={after}")
            print(f"filelock_version={filelock.__version__}")
            print(f"class_changed={class_changed}")
            print(f"warning_count={len(messages)}")
            print(f"warning={warning}")
            print(f"locked={lock.is_locked}")
            reproduced = (
                before == "UnixFileLock"
                and after == "SoftFileLock"
                and class_changed
                and len(messages) == 1
                and "falling back to SoftFileLock" in str(warning)
                and lock.is_locked
            )
        finally:
            if lock.is_locked:
                lock.release()

        return 0 if reproduced else 1


if __name__ == "__main__":
    raise SystemExit(main())

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions