fuse4dbricks 0.7.4__tar.gz → 0.7.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/CHANGELOG.md +11 -0
  2. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/PKG-INFO +7 -1
  3. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/README.md +6 -0
  4. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/provider.py +71 -74
  5. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/data_manager.py +13 -9
  6. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/inode_manager.py +13 -0
  7. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/metadata_manager.py +1 -1
  8. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/operations.py +2 -6
  9. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/main.py +1 -0
  10. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/persistence.py +15 -0
  11. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/pyproject.toml +1 -1
  12. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_auth_provider.py +66 -90
  13. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_data_manager.py +81 -1
  14. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_persistence.py +27 -0
  15. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/.env.example +0 -0
  16. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/.github/workflows/test.yml +0 -0
  17. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/.gitignore +0 -0
  18. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/__init__.py +0 -0
  19. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/api/__init__.py +0 -0
  20. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/api/errors.py +0 -0
  21. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/api/uc_client.py +0 -0
  22. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/auth/__init__.py +0 -0
  23. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/__init__.py +0 -0
  24. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/auth_manager.py +0 -0
  25. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/ram_cache.py +0 -0
  26. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/securable_filter.py +0 -0
  27. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/utils.py +0 -0
  28. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/fs/write_buffer.py +0 -0
  29. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/fuse4dbricks/storage/__init__.py +0 -0
  30. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_auth_manager.py +0 -0
  31. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_e2e_mount.py +0 -0
  32. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_inode_manager.py +0 -0
  33. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_main.py +0 -0
  34. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_metadata_manager.py +0 -0
  35. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_operations.py +0 -0
  36. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_securable_filter.py +0 -0
  37. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_uc_client.py +0 -0
  38. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_uc_client_consistency.py +0 -0
  39. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_uc_client_live.py +0 -0
  40. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_utils.py +0 -0
  41. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/tests/test_write_buffer.py +0 -0
  42. {fuse4dbricks-0.7.4 → fuse4dbricks-0.7.5}/uv.lock +0 -0
@@ -1,3 +1,14 @@
1
+ # 0.7.5 (2026-08-06)
2
+
3
+ - Fix fatal startup/runtime errors exiting with status 0 instead of 1.
4
+ - Fix a race in Databricks config file validation where ownership was
5
+ checked on a path and the file was read from that same path separately.
6
+ Ownership is now checked on the open file descriptor that is read.
7
+ - Fix prefetch reading already-downloaded chunks back from disk on every
8
+ read, instead of just checking they exist. Reading a large file
9
+ sequentially could pull roughly an order of magnitude more bytes off
10
+ local disk than the file actually contains.
11
+
1
12
  # 0.7.4 (2026-06-20)
2
13
 
3
14
  - Make writes durable and immediately visible on `close()`. The upload ran only
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fuse4dbricks
3
- Version: 0.7.4
3
+ Version: 0.7.5
4
4
  Summary: FUSE driver for Databricks Unity Catalog Volumes.
5
5
  Project-URL: Homepage, https://github.com/zeehio/fuse4dbricks
6
6
  Author-email: Sergio Oller <sergioller@gmail.com>
@@ -322,6 +322,12 @@ discussing them, so feel free to open an issue if any of them is a problem for y
322
322
  appear until this short TTL expires. Unlike file metadata, these negative results are *not*
323
323
  shared between users.
324
324
 
325
+ - **Revoked permissions can stay effective for a short time.** Catalog and
326
+ schema access permissions are cached using `--metadata-cache-ttl-catalog-sec`,
327
+ set to 10 minutes (600 seconds) by default. Volume access permissions are
328
+ cached using `--metadata-cache-ttl-sec`, set to 30 seconds by default. This
329
+ reduces the request load on the Unity Catalog API, improving performance.
330
+
325
331
  - **Writes are object-store semantics, not POSIX semantics.** The Databricks Files API is a
326
332
  full-replace store: a file is not visible to other processes until the writing process closes
327
333
  it (`release`). Concurrent writers follow last-write-wins — there is no locking. Deleting a
@@ -299,6 +299,12 @@ discussing them, so feel free to open an issue if any of them is a problem for y
299
299
  appear until this short TTL expires. Unlike file metadata, these negative results are *not*
300
300
  shared between users.
301
301
 
302
+ - **Revoked permissions can stay effective for a short time.** Catalog and
303
+ schema access permissions are cached using `--metadata-cache-ttl-catalog-sec`,
304
+ set to 10 minutes (600 seconds) by default. Volume access permissions are
305
+ cached using `--metadata-cache-ttl-sec`, set to 30 seconds by default. This
306
+ reduces the request load on the Unity Catalog API, improving performance.
307
+
302
308
  - **Writes are object-store semantics, not POSIX semantics.** The Databricks Files API is a
303
309
  full-replace store: a file is not visible to other processes until the writing process closes
304
310
  it (`release`). Concurrent writers follow last-write-wins — there is no locking. Deleting a
@@ -156,114 +156,111 @@ class DatabricksUnifiedAuthProvider:
156
156
  logger.error("Failed to read environ file %s for pid %s. Exception: %s", environ_file, pid, exc)
157
157
  return None
158
158
 
159
- def _is_safe_config_file(self, config_file: str, uid: int) -> bool:
160
- """Return True only if ``config_file`` is safe to read on behalf of ``uid``.
161
-
162
- When fuse4dbricks runs as root (the allow_other deployment), it reads
163
- config files out of arbitrary users' home directories. A user who
164
- controls their own ``~/.databrickscfg`` could symlink it at another
165
- user's config file; root would follow the link and cache the *victim's*
166
- token under the *attacker's* uid. ``os.stat`` follows symlinks, so the
167
- ``st_uid == uid`` check is what defeats that: a symlinked-to victim file
168
- is owned by the victim, not the requesting uid, and is rejected. This is
169
- applied to *every* config file path, env-supplied or default, never just
170
- one branch.
159
+ def _read_token_from_path(
160
+ self, config_file: str, uid: int, profile: str
161
+ ) -> tuple[bool, Optional[str]]:
162
+ """Reads ``profile``'s token from ``config_file`` on behalf of ``uid``.
163
+
164
+ Returns ``(True, token)`` if ``config_file`` exists and passes the
165
+ ownership/type checks below (``token`` is ``None`` if the profile or
166
+ its token key is missing -- a content error, not a trust failure, so
167
+ it is not retried against another location). Returns ``(False,
168
+ None)`` if the path does not exist or fails validation, so the
169
+ caller knows to fall back to the next candidate location.
170
+
171
+ fuse4dbricks may run as root and read config files out of arbitrary
172
+ users' home directories (the allow_other deployment), so a path is
173
+ trusted only if it is owned by ``uid``. The file is opened once;
174
+ ownership is checked with ``fstat`` on that descriptor, and the token
175
+ is read from the same descriptor -- never a second, independent
176
+ lookup of ``config_file`` by name -- so the ownership check and the
177
+ read always refer to the same file.
171
178
  """
172
179
  try:
173
- st = os.stat(config_file)
180
+ fd = os.open(config_file, os.O_RDONLY)
174
181
  except FileNotFoundError:
175
182
  # A missing config file is the normal state for any uid without a
176
183
  # Databricks setup (e.g. root or a system daemon probing the mount).
177
184
  # Logging it at error level floods the journal with one line per
178
185
  # request, so keep it at debug.
179
186
  logger.debug("Config file %s does not exist, ignoring", config_file)
180
- return False
187
+ return False, None
181
188
  except OSError as exc:
182
- # Other stat failures (permission denied, etc.) are unexpected and
189
+ # Other open failures (permission denied, etc.) are unexpected and
183
190
  # worth surfacing, but are not fatal to the mount.
191
+ logger.warning("Cannot open config file %s: %s", config_file, exc)
192
+ return False, None
193
+
194
+ try:
195
+ st = os.fstat(fd)
196
+ except OSError as exc:
184
197
  logger.warning("Cannot stat config file %s: %s", config_file, exc)
185
- return False
198
+ os.close(fd)
199
+ return False, None
186
200
  if not stat.S_ISREG(st.st_mode):
187
201
  logger.error("Config file %s is not a regular file, ignoring", config_file)
188
- return False
202
+ os.close(fd)
203
+ return False, None
189
204
  if st.st_uid != uid or not (st.st_mode & stat.S_IRUSR):
190
205
  logger.error(
191
206
  "Config file %s is not owned and readable by uid %s, ignoring",
192
207
  config_file, uid,
193
208
  )
194
- return False
195
- return True
209
+ os.close(fd)
210
+ return False, None
196
211
 
197
- def _get_profile_and_config_file_name(self, env: dict[str, str]|None, uid: int) -> tuple[str, str] | tuple[None, None]:
198
- """ Gets the databricks profile and config file name.
199
- 1. Check if env defines DATABRICKS_CONFIG_PROFILE and DATABRICKS_CONFIG_FILE
200
- 2. If not, default to DATABRICKS_CONFIG_PROFILE=DEFAULT and DATABRICKS_CONFIG_FILE=~/.databrickscfg (in the user's home directory)
201
-
202
- Whichever path is selected must be owned by and readable for ``uid``
203
- (see ``_is_safe_config_file``); an env-supplied path that fails the
204
- check falls through to the default location.
205
- """
206
- if env is not None:
207
- profile = env.get("DATABRICKS_CONFIG_PROFILE", "DEFAULT")
208
- config_file = env.get("DATABRICKS_CONFIG_FILE")
209
- else:
210
- profile = "DEFAULT"
211
- config_file = None
212
-
213
- # An env-supplied config file that fails validation is ignored, falling
214
- # through to the default location below.
215
- if config_file is not None and not self._is_safe_config_file(config_file, uid):
216
- config_file = None
217
-
218
- if config_file is None:
219
- # Default to ~/.databrickscfg in the user's home directory. This path
220
- # is validated identically: under root it may live in another user's
221
- # home and must still be owned by the requesting uid.
222
- home_dir = self._home_for_uid(uid)
223
- default_config = os.path.join(home_dir, ".databrickscfg")
224
- if self._is_safe_config_file(default_config, uid):
225
- config_file = default_config
226
-
227
- if config_file is None:
228
- # Expected for any uid without a Databricks config; debug-level so a
229
- # repeatedly-probing uid (e.g. root) does not flood the journal.
230
- logger.debug("No databricks config file found for uid %s. Checked environment variable DATABRICKS_CONFIG_FILE and default location", uid)
231
- return None, None
232
- return profile, config_file
233
-
234
- def _get_token_from_config(self, profile: str, config_file: str) -> Optional[str]:
235
- """ Reads the databricks token from the config file for the given profile. """
236
212
  config = configparser.ConfigParser()
237
213
  try:
238
- config.read(config_file)
214
+ with os.fdopen(fd, "r") as f:
215
+ config.read_file(f)
239
216
  except Exception as exc:
240
217
  logger.error("Failed to read databricks config file at %s for profile %s. Exception: %s\n%s", config_file, profile, exc, traceback.format_exc())
241
- return None
218
+ return True, None
219
+
242
220
  if profile not in config:
243
221
  logger.error("Profile '%s' not found in %s", profile, config_file)
244
- return None
222
+ return True, None
245
223
  try:
246
- token = config[profile]["token"]
247
- return token
224
+ return True, config[profile]["token"]
248
225
  except KeyError:
249
226
  logger.error(f"'token' not found in profile '{profile}' of {config_file}")
250
- return None
227
+ return True, None
251
228
 
252
229
  async def get_access_token(self, ctx: pyfuse3.RequestContext) -> str|None:
253
- """ Gets the databricks token for the given request context by checking the environment variables of the requesting process and then the config file. """
230
+ """ Gets the databricks token for the given request context by checking the environment variables of the requesting process and then the config file.
231
+ 1. Check if the process defines DATABRICKS_TOKEN in its environment.
232
+ 2. Otherwise, look up a config file: DATABRICKS_CONFIG_PROFILE / DATABRICKS_CONFIG_FILE
233
+ from the process environment if set, falling back to profile DEFAULT and
234
+ ~/.databrickscfg in the uid's home directory. An env-supplied config file that
235
+ fails validation (see ``_read_token_from_path``) falls through to the default.
236
+ """
254
237
  env = self._get_env_for_pid(ctx.pid)
255
238
  # The process defines an access token:
256
239
  if env is not None and "DATABRICKS_TOKEN" in env:
257
240
  return env["DATABRICKS_TOKEN"]
258
- # The process does not define a token, but defines a profile or config file:
259
- profile, config_file = self._get_profile_and_config_file_name(env, ctx.uid)
260
- if profile is None or config_file is None:
261
- # Routine when a uid has neither a token in its environment nor a
262
- # config file; the caller turns this into EACCES. Debug-level to
263
- # avoid one error line per request from a probing uid.
264
- logger.debug("No databricks token found in environment variables for pid %s, and no profile or config file found for uid %s", ctx.pid, ctx.uid)
265
- return None
266
- return self._get_token_from_config(profile, config_file)
241
+
242
+ if env is not None:
243
+ profile = env.get("DATABRICKS_CONFIG_PROFILE", "DEFAULT")
244
+ config_file = env.get("DATABRICKS_CONFIG_FILE")
245
+ else:
246
+ profile = "DEFAULT"
247
+ config_file = None
248
+
249
+ if config_file is not None:
250
+ found, token = self._read_token_from_path(config_file, ctx.uid, profile)
251
+ if found:
252
+ return token
253
+
254
+ home_dir = self._home_for_uid(ctx.uid)
255
+ default_config = os.path.join(home_dir, ".databrickscfg")
256
+ found, token = self._read_token_from_path(default_config, ctx.uid, profile)
257
+ if found:
258
+ return token
259
+
260
+ # Expected for any uid without a Databricks config; debug-level so a
261
+ # repeatedly-probing uid (e.g. root) does not flood the journal.
262
+ logger.debug("No databricks token found in environment variables for pid %s, and no profile or config file found for uid %s", ctx.pid, ctx.uid)
263
+ return None
267
264
 
268
265
  class AuthProvider:
269
266
  # Key for the shared token in single-principal mode. -1 is never a valid uid,
@@ -159,14 +159,21 @@ class DataManager:
159
159
  async def _process_request(self, chunk_request: _ChunkRequest, priority: str):
160
160
  try:
161
161
  cache_key = (chunk_request.fs_path, chunk_request.chunk_id, chunk_request.mtime, chunk_request.gen)
162
- # We have a chunk_request to download
163
- # check if we already downloaded it:
164
- chunk = await self.persistence.retrieve_chunk(fs_path=chunk_request.fs_path, chunk_index=chunk_request.chunk_id, mtime=chunk_request.mtime, gen=chunk_request.gen)
165
- if chunk is None:
166
- # download it.
167
- chunk = await self._fetch_chunk(chunk_request)
168
162
  if priority == "high":
163
+ # A real read needs the bytes now, so fetch them (from disk,
164
+ # falling back to network) and cache in RAM.
165
+ chunk = await self.persistence.retrieve_chunk(fs_path=chunk_request.fs_path, chunk_index=chunk_request.chunk_id, mtime=chunk_request.mtime, gen=chunk_request.gen)
166
+ if chunk is None:
167
+ chunk = await self._fetch_chunk(chunk_request)
169
168
  await self._ram_cache.put(cache_key, chunk)
169
+ else:
170
+ # A prefetch only needs the chunk to end up on disk. Reading
171
+ # it back (as retrieve_chunk does) would cost as much disk
172
+ # I/O as the read it's trying to save, for a chunk that may
173
+ # not even be read; a plain existence check is enough to
174
+ # decide whether a download is needed.
175
+ if not await self.persistence.chunk_exists(fs_path=chunk_request.fs_path, chunk_index=chunk_request.chunk_id, mtime=chunk_request.mtime, gen=chunk_request.gen):
176
+ await self._fetch_chunk(chunk_request)
170
177
  finally:
171
178
  await self._inflight_coalescer.notify_done(cache_key)
172
179
 
@@ -317,6 +324,3 @@ class DataManager:
317
324
  )
318
325
  result.extend(chunk_data[chunk_start:chunk_end])
319
326
  return bytes(result)
320
-
321
- async def write(self, fs_path: str, offset: int, buffer: bytes, ctx: pyfuse3.RequestContext) -> int:
322
- raise pyfuse3.FUSEError(errno.EACCES)
@@ -134,6 +134,19 @@ class InodeManager:
134
134
  if entry.ref_count <= 0:
135
135
  self._delete_inode_internal(inode)
136
136
 
137
+ def detach_path(self, fs_path: str) -> None:
138
+ """Detach ``fs_path`` -- and, if it is a directory, everything
139
+ beneath it -- from the tree after it has been deleted remotely.
140
+
141
+ A no-op if ``fs_path`` has no known inode. This only touches the
142
+ in-process tree; it does not tell the kernel to drop its own cached
143
+ attributes/dentries for the path (see
144
+ ``MetadataManager._invalidate_kernel_cache`` for that).
145
+ """
146
+ inode = self.get_inode_by_path(fs_path)
147
+ if inode is not None:
148
+ self._prune_subtree(inode)
149
+
137
150
  def add_entry(
138
151
  self, parent_inode: int, name: str, attr: InodeEntryAttr
139
152
  ):
@@ -504,7 +504,7 @@ class MetadataManager:
504
504
  if parent_path in self._dir_cache:
505
505
  del self._dir_cache[parent_path]
506
506
  except Exception:
507
- pass
507
+ logger.warning("Cache invalidation failed for %s", fs_path, exc_info=True)
508
508
 
509
509
  # Evicting our own caches is not enough: the kernel keeps its own
510
510
  # attribute and dentry caches (for up to attr_timeout / entry_timeout),
@@ -704,9 +704,7 @@ class UnityCatalogFS(pyfuse3.Operations):
704
704
  except Exception as e:
705
705
  self._raise_fuse_error(e, fs_path=child_fs_path, op="unlink")
706
706
 
707
- child_inode = self.inodes.get_inode_by_path(child_fs_path)
708
- if child_inode is not None:
709
- self.inodes._prune_subtree(child_inode)
707
+ self.inodes.detach_path(child_fs_path)
710
708
  self.metadata_manager.invalidate(child_fs_path, is_dir=False)
711
709
  # Drop any cached content so a later same-named file is not served stale.
712
710
  await self.data_manager.invalidate_path(child_fs_path)
@@ -734,9 +732,7 @@ class UnityCatalogFS(pyfuse3.Operations):
734
732
  except Exception as e:
735
733
  self._raise_fuse_error(e, fs_path=child_fs_path, op="rmdir")
736
734
 
737
- child_inode = self.inodes.get_inode_by_path(child_fs_path)
738
- if child_inode is not None:
739
- self.inodes._prune_subtree(child_inode)
735
+ self.inodes.detach_path(child_fs_path)
740
736
  self.metadata_manager.invalidate(child_fs_path, is_dir=True)
741
737
 
742
738
  async def rename(self, parent_inode_old, name_old, parent_inode_new, name_new, flags, ctx):
@@ -283,6 +283,7 @@ async def async_main():
283
283
  logger.error("Top-level group message: %s", eg)
284
284
  for i, exc in enumerate(eg.exceptions, 1):
285
285
  logger.error("[%d] type=%s -> %s\n%s", i, type(exc).__name__, exc, traceback.format_exc())
286
+ raise
286
287
  finally:
287
288
  data_manager.close()
288
289
  await uc_client.close()
@@ -136,6 +136,21 @@ class DiskPersistence:
136
136
  with open(path, "rb") as f:
137
137
  return f.read()
138
138
 
139
+ async def chunk_exists(
140
+ self, fs_path: str, chunk_index: int, mtime: float, gen: int = 0
141
+ ) -> bool:
142
+ """Whether a chunk is already cached on disk, without reading its
143
+ content. For a prefetch, the caller only needs to know whether a
144
+ download is required; reading the full chunk back (as
145
+ ``retrieve_chunk`` does) would cost as much I/O as the read it is
146
+ trying to save.
147
+ """
148
+ cache_path = self._get_chunk_path(fs_path, chunk_index, mtime, gen)
149
+ async with self.lock:
150
+ if cache_path in self.access_map:
151
+ self.access_map[cache_path] = time.time()
152
+ return await trio.to_thread.run_sync(os.path.exists, cache_path)
153
+
139
154
  async def store_chunk_from_stream(
140
155
  self,
141
156
  fs_path: str,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fuse4dbricks"
7
- version = "0.7.4"
7
+ version = "0.7.5"
8
8
  description = "FUSE driver for Databricks Unity Catalog Volumes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,8 +4,7 @@ Tests for fuse4dbricks.auth.provider.
4
4
  Covers the multi-user trust boundary:
5
5
  - _subuid_owner / _home_for_uid (uid -> home, including subuid ranges)
6
6
  - _get_env_for_pid (binary /proc/<pid>/environ parsing)
7
- - _get_profile_and_config_file_name (ownership/readability checks)
8
- - _get_token_from_config (config parsing)
7
+ - _read_token_from_path (atomic open+fstat ownership check, then parse)
9
8
  - DatabricksUnifiedAuthProvider.get_access_token (end-to-end resolution)
10
9
  - AuthProvider (local-token precedence, invalidation, EACCES)
11
10
  """
@@ -115,121 +114,77 @@ def test_get_env_for_pid_parses_and_skips_non_utf8(unified, monkeypatch):
115
114
 
116
115
 
117
116
  # ---------------------------------------------------------------------------
118
- # _get_profile_and_config_file_name (ownership / readability)
117
+ # _read_token_from_path (atomic open+fstat ownership check, then parse)
119
118
  # ---------------------------------------------------------------------------
120
119
 
121
120
 
122
- def test_profile_config_from_env_owned_by_uid(unified, tmp_path):
121
+ def test_read_token_from_path_owned_by_uid(unified, tmp_path):
123
122
  cfg = tmp_path / "cfg"
124
- cfg.write_text("[DEFAULT]\ntoken=x\n")
125
- uid = os.getuid()
126
- env = {"DATABRICKS_CONFIG_PROFILE": "PROD", "DATABRICKS_CONFIG_FILE": str(cfg)}
127
- profile, config_file = unified._get_profile_and_config_file_name(env, uid)
128
- assert profile == "PROD"
129
- assert config_file == str(cfg)
130
-
131
-
132
- def test_profile_config_env_file_not_owned_is_ignored(unified, tmp_path, monkeypatch):
123
+ cfg.write_text("[PROD]\ntoken = dapi-prod\n")
124
+ found, token = unified._read_token_from_path(str(cfg), os.getuid(), "PROD")
125
+ assert (found, token) == (True, "dapi-prod")
126
+
127
+
128
+ def test_read_token_from_path_not_owned_is_rejected(unified, tmp_path):
129
+ # Trust-boundary regression: under root + allow_other, a config file (or
130
+ # a symlink swapped in to point at another user's file) not owned by the
131
+ # requesting uid must be rejected — root must not cache the victim's
132
+ # token under the attacker's uid. Ownership is checked with fstat() on
133
+ # the same fd that is then read, so there is no separate check-then-open
134
+ # step for a swapped path to land in between.
133
135
  cfg = tmp_path / "cfg"
134
- cfg.write_text("[DEFAULT]\ntoken=x\n")
135
- # A uid that does not own the temp file -> the env config file is rejected
136
- # and we fall back to the home default (which we point at an empty dir).
137
- monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "emptyhome"))
138
- env = {"DATABRICKS_CONFIG_FILE": str(cfg)}
139
- profile, config_file = unified._get_profile_and_config_file_name(env, os.getuid() + 424242)
140
- assert (profile, config_file) == (None, None)
136
+ cfg.write_text("[DEFAULT]\ntoken = victim-secret\n")
137
+ found, token = unified._read_token_from_path(str(cfg), os.getuid() + 424242, "DEFAULT")
138
+ assert (found, token) == (False, None)
141
139
 
142
140
 
143
- def test_profile_config_env_file_not_regular_is_ignored(unified, tmp_path, monkeypatch):
141
+ def test_read_token_from_path_not_regular_is_rejected(unified, tmp_path):
144
142
  d = tmp_path / "adir"
145
143
  d.mkdir()
146
- monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "emptyhome"))
147
- env = {"DATABRICKS_CONFIG_FILE": str(d)}
148
- profile, config_file = unified._get_profile_and_config_file_name(env, os.getuid())
149
- assert (profile, config_file) == (None, None)
144
+ found, token = unified._read_token_from_path(str(d), os.getuid(), "DEFAULT")
145
+ assert (found, token) == (False, None)
150
146
 
151
147
 
152
- def test_profile_config_default_home_file(unified, tmp_path, monkeypatch):
153
- home = tmp_path / "home"
154
- home.mkdir()
155
- (home / ".databrickscfg").write_text("[DEFAULT]\ntoken=x\n")
156
- monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(home))
157
- # The default file must also pass the ownership check, so the resolved uid
158
- # has to match the file owner (the test user that just created it).
159
- profile, config_file = unified._get_profile_and_config_file_name(env=None, uid=os.getuid())
160
- assert profile == "DEFAULT"
161
- assert config_file == str(home / ".databrickscfg")
162
-
163
-
164
- def test_profile_config_default_home_file_not_owned_is_rejected(unified, tmp_path, monkeypatch):
165
- # Trust-boundary regression: under root + allow_other, the default
166
- # ~/.databrickscfg is resolved from another user's home and could be a
167
- # symlink the user planted to a victim's config. os.stat follows the link,
168
- # so the file appears owned by the victim (not the requesting uid) and must
169
- # be rejected — root must not cache the victim's token under the attacker.
170
- home = tmp_path / "home"
171
- home.mkdir()
172
- (home / ".databrickscfg").write_text("[DEFAULT]\ntoken=victim-secret\n")
173
- monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(home))
174
- # A uid that does not own the file -> rejected, nothing resolved.
175
- profile, config_file = unified._get_profile_and_config_file_name(
176
- env=None, uid=os.getuid() + 424242
177
- )
178
- assert (profile, config_file) == (None, None)
179
-
180
-
181
- def test_profile_config_nothing_found(unified, tmp_path, monkeypatch):
182
- monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
183
- profile, config_file = unified._get_profile_and_config_file_name(env=None, uid=os.getuid())
184
- assert (profile, config_file) == (None, None)
185
-
186
-
187
- def test_missing_default_config_is_not_logged_as_error(unified, tmp_path, monkeypatch, caplog):
188
- """A missing ~/.databrickscfg is the normal state for a uid without a
148
+ def test_read_token_from_path_missing_file_is_not_logged_as_error(unified, tmp_path, caplog):
149
+ """A missing config file is the normal state for a uid without a
189
150
  Databricks setup (e.g. root probing the mount). It must not be logged at
190
151
  error level, or a repeatedly-probing uid floods the journal with one line
191
152
  per request."""
192
- monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
193
153
  with caplog.at_level("DEBUG", logger="fuse4dbricks.auth.provider"):
194
- profile, config_file = unified._get_profile_and_config_file_name(env=None, uid=os.getuid())
195
- assert (profile, config_file) == (None, None)
196
- # The benign "does not exist" path logs at debug, never at error/warning.
154
+ found, token = unified._read_token_from_path(str(tmp_path / "nope"), os.getuid(), "DEFAULT")
155
+ assert (found, token) == (False, None)
197
156
  assert not [r for r in caplog.records if r.levelname in ("ERROR", "WARNING")]
198
157
  assert any(r.levelname == "DEBUG" for r in caplog.records)
199
158
 
200
159
 
201
- def test_unreadable_config_is_logged_as_warning(unified, tmp_path, monkeypatch, caplog):
202
- """A stat failure that is NOT 'file missing' (e.g. permission denied) is
203
- unexpected and stays visible at warning level."""
204
- def _boom(path):
160
+ def test_read_token_from_path_open_failure_is_logged_as_warning(unified, tmp_path, monkeypatch, caplog):
161
+ """An open() failure that is NOT 'file missing' (e.g. permission denied)
162
+ is unexpected and stays visible at warning level."""
163
+ def _boom(path, flags):
205
164
  raise PermissionError("denied")
206
- monkeypatch.setattr(provider_mod.os, "stat", _boom)
165
+ monkeypatch.setattr(provider_mod.os, "open", _boom)
207
166
  with caplog.at_level("DEBUG", logger="fuse4dbricks.auth.provider"):
208
- assert unified._is_safe_config_file(str(tmp_path / "cfg"), os.getuid()) is False
167
+ found, token = unified._read_token_from_path(str(tmp_path / "cfg"), os.getuid(), "DEFAULT")
168
+ assert (found, token) == (False, None)
209
169
  assert any(r.levelname == "WARNING" for r in caplog.records)
210
170
 
211
171
 
212
- # ---------------------------------------------------------------------------
213
- # _get_token_from_config
214
- # ---------------------------------------------------------------------------
215
-
216
-
217
- def test_get_token_from_config_reads_token(unified, tmp_path):
172
+ def test_read_token_from_path_reads_token(unified, tmp_path):
218
173
  cfg = tmp_path / "cfg"
219
174
  cfg.write_text("[DEFAULT]\ntoken = dapi-default\n\n[PROD]\ntoken = dapi-prod\n")
220
- assert unified._get_token_from_config("PROD", str(cfg)) == "dapi-prod"
175
+ assert unified._read_token_from_path(str(cfg), os.getuid(), "PROD") == (True, "dapi-prod")
221
176
 
222
177
 
223
- def test_get_token_from_config_missing_profile(unified, tmp_path):
178
+ def test_read_token_from_path_missing_profile(unified, tmp_path):
224
179
  cfg = tmp_path / "cfg"
225
180
  cfg.write_text("[DEFAULT]\ntoken = x\n")
226
- assert unified._get_token_from_config("NOPE", str(cfg)) is None
181
+ assert unified._read_token_from_path(str(cfg), os.getuid(), "NOPE") == (True, None)
227
182
 
228
183
 
229
- def test_get_token_from_config_missing_token_key(unified, tmp_path):
184
+ def test_read_token_from_path_missing_token_key(unified, tmp_path):
230
185
  cfg = tmp_path / "cfg"
231
186
  cfg.write_text("[DEFAULT]\nhost = https://x\n")
232
- assert unified._get_token_from_config("DEFAULT", str(cfg)) is None
187
+ assert unified._read_token_from_path(str(cfg), os.getuid(), "DEFAULT") == (True, None)
233
188
 
234
189
 
235
190
  # ---------------------------------------------------------------------------
@@ -244,21 +199,42 @@ async def test_unified_get_token_prefers_env_token(unified, monkeypatch):
244
199
 
245
200
 
246
201
  @pytest.mark.trio
247
- async def test_unified_get_token_falls_back_to_config(unified, tmp_path, monkeypatch):
202
+ async def test_unified_get_token_uses_env_config_file(unified, tmp_path, monkeypatch):
248
203
  cfg = tmp_path / "cfg"
249
204
  cfg.write_text("[DEFAULT]\ntoken = dapi-cfg\n")
250
- monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: {})
205
+ monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: {"DATABRICKS_CONFIG_FILE": str(cfg)})
206
+ assert await unified.get_access_token(_ctx(uid=os.getuid())) == "dapi-cfg"
207
+
208
+
209
+ @pytest.mark.trio
210
+ async def test_unified_get_token_missing_env_file_falls_back_to_default(unified, tmp_path, monkeypatch):
211
+ home = tmp_path / "home"
212
+ home.mkdir()
213
+ (home / ".databrickscfg").write_text("[DEFAULT]\ntoken = dapi-home\n")
251
214
  monkeypatch.setattr(
252
- unified, "_get_profile_and_config_file_name", lambda env, uid: ("DEFAULT", str(cfg))
215
+ unified, "_get_env_for_pid",
216
+ lambda pid: {"DATABRICKS_CONFIG_FILE": str(tmp_path / "does-not-exist")},
253
217
  )
254
- assert await unified.get_access_token(_ctx()) == "dapi-cfg"
218
+ monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(home))
219
+ assert await unified.get_access_token(_ctx(uid=os.getuid())) == "dapi-home"
220
+
221
+
222
+ @pytest.mark.trio
223
+ async def test_unified_get_token_env_file_not_owned_is_ignored(unified, tmp_path, monkeypatch):
224
+ cfg = tmp_path / "cfg"
225
+ cfg.write_text("[DEFAULT]\ntoken = x\n")
226
+ monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: {"DATABRICKS_CONFIG_FILE": str(cfg)})
227
+ monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "emptyhome"))
228
+ # A uid that does not own cfg -> the env config file is rejected; the
229
+ # default home has no config file either -> nothing resolved.
230
+ assert await unified.get_access_token(_ctx(uid=os.getuid() + 424242)) is None
255
231
 
256
232
 
257
233
  @pytest.mark.trio
258
- async def test_unified_get_token_none_when_unresolvable(unified, monkeypatch):
234
+ async def test_unified_get_token_none_when_unresolvable(unified, tmp_path, monkeypatch):
259
235
  monkeypatch.setattr(unified, "_get_env_for_pid", lambda pid: None)
260
- monkeypatch.setattr(unified, "_get_profile_and_config_file_name", lambda env, uid: (None, None))
261
- assert await unified.get_access_token(_ctx()) is None
236
+ monkeypatch.setattr(unified, "_home_for_uid", lambda uid: str(tmp_path / "empty"))
237
+ assert await unified.get_access_token(_ctx(uid=os.getuid())) is None
262
238
 
263
239
 
264
240
  # ---------------------------------------------------------------------------
@@ -14,7 +14,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
14
14
  import pytest
15
15
  import pyfuse3
16
16
 
17
- from fuse4dbricks.fs.data_manager import DataManager
17
+ from fuse4dbricks.fs.data_manager import DataManager, _ChunkRequest
18
+ from fuse4dbricks.fs.ram_cache import RamCache
18
19
 
19
20
 
20
21
  # ---------------------------------------------------------------------------
@@ -285,6 +286,85 @@ async def test_read_prefetch_stops_at_eof(manager, ctx):
285
286
  assert 3 not in prefetched_chunk_ids # chunk 3 doesn't exist
286
287
 
287
288
 
289
+ # ---------------------------------------------------------------------------
290
+ # Tests: _process_request (on-demand reads vs. prefetch downloads)
291
+ #
292
+ # On-demand ("high" priority) reads need the actual bytes now, so they read
293
+ # the chunk (from disk, falling back to network) and cache it in RAM.
294
+ # Prefetch ("regular" priority) only needs the chunk to end up on disk --
295
+ # reading it back would cost as much disk I/O as the read it's trying to
296
+ # save, for a chunk that may never be read, so it uses a cheap existence
297
+ # check and never populates the RAM cache.
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ def _chunk_request(manager, chunk_id=0, mtime=100.0, gen=0):
302
+ return _ChunkRequest(
303
+ fs_path="/c/s/v/f", chunk_id=chunk_id, mtime=mtime, gen=gen,
304
+ chunk_size=manager.chunk_size, ctx=SimpleNamespace(uid=1000, pid=1, gid=1000),
305
+ )
306
+
307
+
308
+ @pytest.mark.trio
309
+ async def test_process_request_high_priority_disk_hit_caches_in_ram(manager):
310
+ # The `manager` fixture's ram_cache_mb=1 rounds down to 0-entry capacity
311
+ # (1 MB < one 8 MB chunk), which would no-op every put/get below.
312
+ manager._ram_cache = RamCache(max_entries=8)
313
+ manager.persistence.retrieve_chunk = AsyncMock(return_value=b"cached-bytes")
314
+ request = _chunk_request(manager)
315
+
316
+ await manager._process_request(request, priority="high")
317
+
318
+ manager.persistence.store_chunk_from_stream.assert_not_awaited()
319
+ cached = await manager._ram_cache.get(("/c/s/v/f", 0, 100.0, 0))
320
+ assert cached == b"cached-bytes"
321
+
322
+
323
+ @pytest.mark.trio
324
+ async def test_process_request_high_priority_miss_downloads_and_caches_in_ram(manager):
325
+ manager._ram_cache = RamCache(max_entries=8)
326
+ manager.persistence.retrieve_chunk = AsyncMock(return_value=None)
327
+ manager.persistence.store_chunk_from_stream = AsyncMock(return_value=b"downloaded")
328
+ request = _chunk_request(manager)
329
+
330
+ await manager._process_request(request, priority="high")
331
+
332
+ manager.persistence.store_chunk_from_stream.assert_awaited_once()
333
+ cached = await manager._ram_cache.get(("/c/s/v/f", 0, 100.0, 0))
334
+ assert cached == b"downloaded"
335
+
336
+
337
+ @pytest.mark.trio
338
+ async def test_process_request_prefetch_skips_download_when_already_on_disk(manager):
339
+ manager.persistence.chunk_exists = AsyncMock(return_value=True)
340
+ request = _chunk_request(manager, chunk_id=5)
341
+
342
+ await manager._process_request(request, priority="regular")
343
+
344
+ manager.persistence.chunk_exists.assert_awaited_once_with(
345
+ fs_path="/c/s/v/f", chunk_index=5, mtime=100.0, gen=0
346
+ )
347
+ # No download, and -- crucially -- no read of the chunk's content either
348
+ # (that would cost the same disk I/O the existence check is avoiding).
349
+ manager.persistence.retrieve_chunk.assert_not_awaited()
350
+ manager.persistence.store_chunk_from_stream.assert_not_awaited()
351
+ assert await manager._ram_cache.get(("/c/s/v/f", 5, 100.0, 0)) is None
352
+
353
+
354
+ @pytest.mark.trio
355
+ async def test_process_request_prefetch_downloads_when_missing_but_not_cached_in_ram(manager):
356
+ manager.persistence.chunk_exists = AsyncMock(return_value=False)
357
+ manager.persistence.store_chunk_from_stream = AsyncMock(return_value=b"downloaded")
358
+ request = _chunk_request(manager, chunk_id=5)
359
+
360
+ await manager._process_request(request, priority="regular")
361
+
362
+ manager.persistence.retrieve_chunk.assert_not_awaited()
363
+ manager.persistence.store_chunk_from_stream.assert_awaited_once()
364
+ # Prefetched content is deliberately not promoted into the RAM cache.
365
+ assert await manager._ram_cache.get(("/c/s/v/f", 5, 100.0, 0)) is None
366
+
367
+
288
368
  # ---------------------------------------------------------------------------
289
369
  # Tests: cache generation (invalidate_path)
290
370
  # ---------------------------------------------------------------------------
@@ -110,6 +110,33 @@ async def test_retrieve_missing_chunk(persistence):
110
110
  assert result is None
111
111
 
112
112
 
113
+ @pytest.mark.trio
114
+ async def test_chunk_exists_true_after_store(persistence):
115
+ """chunk_exists reports a stored chunk as present, without needing to
116
+ read its content."""
117
+ await persistence.store_chunk_from_stream("f", 0, 5.0, async_byte_generator(b"data"))
118
+ assert await persistence.chunk_exists("f", 0, 5.0) is True
119
+
120
+
121
+ @pytest.mark.trio
122
+ async def test_chunk_exists_false_for_missing_chunk(persistence):
123
+ assert await persistence.chunk_exists("ghost_file", 99, 0.0) is False
124
+
125
+
126
+ @pytest.mark.trio
127
+ async def test_chunk_exists_does_not_read_file_content(persistence, monkeypatch):
128
+ """chunk_exists must not pay the I/O cost of reading the chunk back --
129
+ that's the whole point of using it instead of retrieve_chunk for a
130
+ prefetch's "is a download needed" check."""
131
+ await persistence.store_chunk_from_stream("f", 0, 5.0, async_byte_generator(b"data"))
132
+
133
+ def _boom(path):
134
+ raise AssertionError("chunk_exists must not read the file's content")
135
+
136
+ monkeypatch.setattr(persistence, "_read_file", _boom)
137
+ assert await persistence.chunk_exists("f", 0, 5.0) is True
138
+
139
+
113
140
  @pytest.mark.trio
114
141
  async def test_get_chunk_path_is_pure_no_dir_created(persistence):
115
142
  """_get_chunk_path must not touch the filesystem: it runs on every read,
File without changes
File without changes
File without changes