Repository: Kozea/Radicale

Issue:
Unable to start fresh installed Radicale if `folder_umask` provided explicitly
Hello. 

* OS: Alpine Linux 3.22.0
* Radicale version: 3.5.4-r0 (installed from repository)
* Python version: 3.12.11

Config:
```toml
[server]
hosts = 127.0.0.1:5232
max_connections = 24

[auth]
type = http_x_remote_user

[storage]
filesystem_folder = /data/radicale
folder_umask = 0077
```

Log:
```log
[2025-06-12 18:04:22 +0000] [1761] [INFO] Logging of backtrace is disabled in this loglevel
[2025-06-12 18:04:22 +0000] [1761] [INFO] Loaded default config
[2025-06-12 18:04:22 +0000] [1761] [INFO] Loaded config file '/etc/radicale/config'
[2025-06-12 18:04:22 +0000] [1761] [INFO] Skipped missing/unreadable config file '/var/lib/radicale/.config/radicale/config'
[2025-06-12 18:04:22 +0000] [1761] [INFO] Starting Radicale (python=3.12.11 radicale=3.5.4 vobject=0.9.6.1 passlib=1.7.4 defusedxml=0.7.1 bcrypt=n/a argon2-cffi=n/a pika=n/a ldap=n/a ldap3=n/a pam=n/a)
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth type is 'radicale.auth.http_x_remote_user'
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth.strip_domain: False
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth.lc_username: False
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth.uc_username: False
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth.urldecode_username: False
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth.delay: 1.000000
[2025-06-12 18:04:22 +0000] [1761] [INFO] auth.cache_logins: False
[2025-06-12 18:04:22 +0000] [1761] [INFO] storage type is 'radicale.storage.multifilesystem'
[2025-06-12 18:04:22 +0000] [1761] [INFO] Storage location: '/data/radicale'
[2025-06-12 18:04:22 +0000] [1761] [INFO] Storage location subfolder: '/data/radicale/collection-root'
[2025-06-12 18:04:22 +0000] [1761] [WARNING] Storage location subfolder: '/data/radicale/collection-root' does not exist, creating now
[2025-06-12 18:04:22 +0000] [1761] [CRITICAL] An exception occurred during server startup: 'Storage' object has no attribute '_config_umask'
```

If remove logger [here](https://github.com/Kozea/Radicale/blob/137f2a2ca381e34529b2053fb33b2556333d652e/radicale/__main__.py#L205)
we can see stracktrace:
```
Traceback (most recent call last):
  File "/usr/bin/radicale", line 8, in <module>
    sys.exit(run())
             ^^^^^
  File "/usr/lib/python3.12/site-packages/radicale/__main__.py", line 205, in run
    server.serve(configuration, shutdown_socket_out)
  File "/usr/lib/python3.12/site-packages/radicale/server.py", line 308, in serve
    application = Application(configuration)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/radicale/app/__init__.py", line 84, in __init__
    super().__init__(configuration)
  File "/usr/lib/python3.12/site-packages/radicale/app/base.py", line 49, in __init__
    self._storage = storage.load(configuration)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/radicale/storage/__init__.py", line 52, in load
    return utils.load_plugin(INTERNAL_TYPES, "storage", "Storage", BaseStorage,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/radicale/utils.py", line 61, in load_plugin
    return class_(configuration)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/radicale/storage/multifilesystem/__init__.py", line 156, in __init__
    self._makedirs_synced(self._get_collection_root_folder())
  File "/usr/lib/python3.12/site-packages/radicale/storage/multifilesystem/base.py", line 158, in _makedirs_synced
    oldmask = os.umask(self._config_umask)
                       ^^^^^^^^^^^^^^^^^^
AttributeError: 'Storage' object has no attribute '_config_umask'
```

Seems that [here](https://github.com/Kozea/Radicale/blob/137f2a2ca381e34529b2053fb33b2556333d652e/radicale/storage/multifilesystem/__init__.py#L152) and [here](https://github.com/Kozea/Radicale/blob/137f2a2ca381e34529b2053fb33b2556333d652e/radicale/storage/multifilesystem/__init__.py#L156) function `_makedirs_synced` called **before** `self._config_umask` [initialized](https://github.com/Kozea/Radicale/blob/137f2a2ca381e34529b2053fb33b2556333d652e/radicale/storage/multifilesystem/__init__.py#L195).

Without `folder_umask` provided, starts OK.

Discussion:
@pbiering: please use upstream now or the diff from the PR, fixed now.@sot-tech: Server started correctly:

```log
[2025-06-13 09:57:36 +0000] [2638] [INFO] storage type is 'radicale.storage.multifilesystem'
[2025-06-13 09:57:36 +0000] [2638] [INFO] storage folder umask defined: '0077'
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage location: '/data/radicale'
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage location subfolder: '/data/radicale/collection-root'
[2025-06-13 09:57:36 +0000] [2638] [WARNING] Storage location subfolder: '/data/radicale/collection-root' does not exist, creating now
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage cache subfolder usage for 'item': False
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage cache subfolder usage for 'history': False
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage cache subfolder usage for 'sync-token': False
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage cache use mtime and size for 'item': False
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage item mtime resolution test result: 1 ns
[2025-06-13 09:57:36 +0000] [2638] [INFO] Storage cache using mtime and size for 'item' may be an option in case of performance issues
[2025-06-13 09:57:36 +0000] [2638] [INFO] rights type is 'radicale.rights.owner_only'
[2025-06-13 09:57:36 +0000] [2638] [INFO] web type is 'radicale.web.internal'
[2025-06-13 09:57:36 +0000] [2638] [INFO] hook type is 'radicale.hook.none'
[2025-06-13 09:57:36 +0000] [2638] [INFO] Default script name to strip from URI if called by reverse proxy is taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME
[2025-06-13 09:57:36 +0000] [2638] [INFO] permit delete of collection: True
[2025-06-13 09:57:36 +0000] [2638] [INFO] permit overwrite of collection: True
[2025-06-13 09:57:36 +0000] [2638] [INFO] Listening on '127.0.0.1:5232'
[2025-06-13 09:57:36 +0000] [2638] [INFO] Radicale server ready
```

Thank you!


Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 728e63bf..b5b1c05d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## 3.5.5.dev
 * Improve: [auth] ldap: do not read server info by bind to avoid needless network traffic
+* Fix: [storage] broken support of 'folder_umask'
 
 ## 3.5.4
 * Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)
diff --git a/radicale/storage/multifilesystem/__init__.py b/radicale/storage/multifilesystem/__init__.py
index c8e3316e..005e6af1 100644
--- a/radicale/storage/multifilesystem/__init__.py
+++ b/radicale/storage/multifilesystem/__init__.py
@@ -146,6 +146,21 @@ class Storage(
 
     def __init__(self, configuration: config.Configuration) -> None:
         super().__init__(configuration)
+        if sys.platform != "win32":
+            if not self._folder_umask:
+                # retrieve current umask by setting a dummy umask
+                current_umask = os.umask(0o0022)
+                logger.info("Storage folder umask (from system): '%04o'", current_umask)
+                # reset to original
+                os.umask(current_umask)
+            else:
+                try:
+                    config_umask = int(self._folder_umask, 8)
+                except Exception:
+                    logger.critical("storage folder umask defined but invalid: '%s'", self._folder_umask)
+                    raise
+                logger.info("storage folder umask defined: '%04o'", config_umask)
+                self._config_umask = config_umask
         logger.info("Storage location: %r", self._filesystem_folder)
         if not os.path.exists(self._filesystem_folder):
             logger.warning("Storage location: %r does not exist, creating now", self._filesystem_folder)
@@ -178,18 +193,3 @@ class Storage(
             if not os.path.exists(self._get_collection_cache_folder()):
                 logger.warning("Storage cache subfolder: %r does not exist, creating now", self._get_collection_cache_folder())
                 self._makedirs_synced(self._get_collection_cache_folder())
-        if sys.platform != "win32":
-            if not self._folder_umask:
-                # retrieve current umask by setting a dummy umask
-                current_umask = os.umask(0o0022)
-                logger.info("Storage folder umask (from system): '%04o'", current_umask)
-                # reset to original
-                os.umask(current_umask)
-            else:
-                try:
-                    config_umask = int(self._folder_umask, 8)
-                except Exception:
-                    logger.critical("storage folder umask defined but invalid: '%s'", self._folder_umask)
-                    raise
-                logger.info("storage folder umask defined: '%04o'", config_umask)
-                self._config_umask = config_umask


Test patch:
diff --git a/radicale/tests/test_storage.py b/radicale/tests/test_storage.py
index 2fcfe717..89572bca 100644
--- a/radicale/tests/test_storage.py
+++ b/radicale/tests/test_storage.py
@@ -46,6 +46,12 @@ class TestMultiFileSystem(BaseTest):
         self.configure({"storage": {"filesystem_folder": folder}})
         assert os.path.isdir(folder)
 
+    def test_folder_creation_with_umask(self) -> None:
+        """Verify that the folder is created with umask."""
+        folder = os.path.join(self.colpath, "subfolder")
+        self.configure({"storage": {"filesystem_folder": folder, "folder_umask": "0077"}})
+        assert os.path.isdir(folder)
+
     def test_fsync(self) -> None:
         """Create a directory and file with syncing enabled."""
         self.configure({"storage": {"_filesystem_fsync": "True"}})