Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions distrib/docker/env_setup_netatalk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ dircache mode = ${AFP_DIRCACHE_MODE:-lru}
dircache validation freq = ${AFP_DIRCACHE_VALIDATION_FREQ:-1}
dircache rfork budget = ${AFP_DIRCACHE_RFORK_BUDGET:-0}
dircache rfork maxsize = ${AFP_DIRCACHE_RFORK_MAXSIZE:-1024}
close stale rlocks = ${AFP_CLOSE_STALE_RLOCKS:-no}
legacy icon = $AFP_LEGACY_ICON
log file = /var/log/afpd.log
log level = default:${AFP_LOGLEVEL:-info}
Expand Down
5 changes: 5 additions & 0 deletions doc/manpages/man5/afp.conf.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,11 @@ close vol = *BOOLEAN* (default: *no*) **(G)**
> Whether to close volumes possibly opened by clients when they're removed
from the configuration and the configuration is reloaded.

close stale rlocks = *BOOLEAN* (default: *no*) **(G)**

> Whether to force-close stale forks holding only read byte-range
locks when a client deletes a file.

extmap file = *path* **(G)**

> Sets the path to the file which defines file extension type/creator
Expand Down
23 changes: 23 additions & 0 deletions doc/manual/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,26 @@ For example, when */home* links to */usr/home*:

For a detailed explanation of all available options,
refer to the [afp.conf](afp.conf.5.html) man page.

### Stale fork cleanup on file deletion

When a client deletes a file via AFP (FPDelete), the server checks for
any open forks associated with the file (forks are internal tracking
objects created when the client opens a file for reading and/or writing).
Netatalk automatically cleans up any forks that are stale (client did not
send a close for each open) — meaning they are not dirty (no
pending metadata flush) and hold no locks. This allows file deletion to
succeed even when a client has neglected to close before deleting the file.

By default, forks holding locks of any type (read or write) are considered
active and will prevent the delete from proceeding (the client receives
a busy error). However, if **close stale rlocks** is set to **yes** in
the Global section of *afp.conf*, forks that only hold read locks (and
no write locks) will also be force-closed.

Forks holding write locks always block deletion.

Example:

[Global]
close stale rlocks = yes
35 changes: 30 additions & 5 deletions etc/afpd/filedir.c
Original file line number Diff line number Diff line change
Expand Up @@ -926,14 +926,27 @@

bdestroy(dname);
}
} else if (of_findname(vol, s_path)) {
rc = AFPERR_BUSY;
} else {
/* It's a file. Check for open forks before attempting delete.
* of_findname() is a cheap hash lookup — no syscalls. */
if (of_findname(vol, s_path)) {
/* File has open forks — attempt stale fork cleanup */
if (of_close_stale_forks(obj, s_path) != 0) {
/* Active forks remain (dirty/locked) — cannot delete */
rc = AFPERR_BUSY;
} else if (of_findname(vol, s_path)) {
/* Safety: forks still present after cleanup */
rc = AFPERR_BUSY;
}
}

/* it's a file st_valid should always be true
* only test for ENOENT because EACCES needs
* to read meta data in deletefile
*/
if (s_path->st_valid && s_path->st_errno == ENOENT) {
if (rc != AFP_OK) {
/* Fork check or stale cleanup failed — skip deletion */
} else if (s_path->st_valid && s_path->st_errno == ENOENT) {
rc = AFPERR_NOOBJ;
} else {
/* Validate target FILE inode to detect external replacement */
Expand Down Expand Up @@ -969,8 +982,20 @@
file_cnid = cnid_get(vol->v_cdb, curdir->d_did, upath, ulen);
}

/* deletefile() also handles CNID and dircache cleanup */
if ((rc = deletefile(vol, -1, upath, 1)) == AFP_OK) {
/* Optimistic delete — try without fork pre-check.
* If it fails with AFPERR_BUSY (open forks hold locks),
* attempt stale fork cleanup and retry once. */
rc = deletefile(vol, -1, upath, 1);

if (rc == AFPERR_BUSY && of_findname(vol, s_path)) {
/* Delete blocked by open forks — try cleaning stale ones */
if (of_close_stale_forks(obj, s_path) == 0) {

Check warning on line 992 in etc/afpd/filedir.c

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this "if" statement with the enclosing one.

See more on https://sonarcloud.io/project/issues?id=Netatalk_netatalk&issues=AZ1dWos3V80h1Qdmq6HR&open=AZ1dWos3V80h1Qdmq6HR&pullRequest=2851
/* All stale forks closed — retry delete */
rc = deletefile(vol, -1, upath, 1);
}
}

if (rc == AFP_OK) {
fce_register(obj, FCE_FILE_DELETE, fullpathname(upath), NULL);

/* Send hints to afpd siblings — file deleted */
Expand Down
1 change: 1 addition & 0 deletions etc/afpd/fork.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ extern void of_pforkdesc(FILE *);
extern int of_stat(const struct vol *vol, struct path *);
extern int of_statdir(struct vol *vol, struct path *);
extern int of_closefork(const AFPObj *obj, struct ofork *ofork);
extern int of_close_stale_forks(const AFPObj *, struct path *);
extern void of_closevol(const AFPObj *obj, const struct vol *vol);
extern void of_close_all_forks(const AFPObj *obj);
extern struct adouble *of_ad(const struct vol *, struct path *,
Expand Down
135 changes: 134 additions & 1 deletion etc/afpd/ofork.c
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ of_alloc(struct vol *vol,
lastrefnum = refnum;

if (i == nforks) {
LOG(log_error, logtype_afpd, "of_alloc: maximum number of forks exceeded.");
LOG(log_error, logtype_afpd,
"of_alloc: maximum number of forks (%d) reached for "
"\"%s\"", nforks, path);
return NULL;
}

Expand Down Expand Up @@ -670,6 +672,117 @@ int of_closefork(const AFPObj *obj, struct ofork *ofork)
return ret;
}

/*!
* @brief Force-close stale forks for a file to unblock deletion
*
* Iterates all open forks matching the target file/directory
* (by dev/ino) and closes any that are safe to force-close:
* - Not DIRTY (no pending AD header changes)
* - No outstanding locks on the shared adouble
*
* AFPFORK_MODIFIED is intentionally not checked: since the file is
* being deleted, any data written via FPWrite is already on disk and
* will be destroyed by the subsequent unlink().
*
* @param obj AFP object (session context)
* @param path Path structure with valid st_dev/st_ino
*
* @return 0 if all forks were closed (or none found), -1 if any
* active fork remains that cannot be safely closed
*/
int of_close_stale_forks(const AFPObj *obj, struct path *path)
{
struct file_key key;
int active_remaining = 0;

if (!path->st_valid || path->st_errno) {
return -1;
}

key.dev = path->st.st_dev;
key.inode = path->st.st_ino;
struct ofork *of = ofork_table[hashfn(&key)];

while (of != NULL) {
struct ofork *next = of->next; /* save before potential dealloc */

if (key.dev != of->key.dev || key.inode != of->key.inode) {
/* Different file — skip */
} else if (!of->of_ad) {
/* Guard: of_ad must be valid for of_name() and of_closefork() */
LOG(log_error, logtype_afpd,
"of_close_stale_forks: fork refnum:%u has NULL adouble, "
"skipping", of->of_refnum);
active_remaining++;
} else if (of->of_flags & AFPFORK_ACCWR) {
/* Safety check 1: fork opened with write access — the client
* may write more data at any time. Never force-close. */
LOG(log_warning, logtype_afpd,
"of_close_stale_forks: fork refnum:%u opened for write, "
"skipping", of->of_refnum);
active_remaining++;
} else if (of->of_flags & AFPFORK_DIRTY) {
/* Safety check 2: Is DIRTY - pending flush */
LOG(log_warning, logtype_afpd,
"of_close_stale_forks: fork refnum:%u has DIRTY flag, "
"skipping (pending flush)",
of->of_refnum);
active_remaining++;
} else if (adf_has_wrlocks(&of->of_ad->ad_data_fork) ||
adf_has_wrlocks(of->of_ad->ad_rfp)) {
/* Safety check 3: write locks
* Write locks indicate a potentially active writer. */
LOG(log_warning, logtype_afpd,
"of_close_stale_forks: fork refnum:%u — adouble holds "
"write locks (data:%d rsrc:%d total), skipping",
of->of_refnum,
of->of_ad->ad_data_fork.adf_lockcount,
of->of_ad->ad_rfp->adf_lockcount);
active_remaining++;
} else if ((of->of_ad->ad_data_fork.adf_lockcount > 0 ||
of->of_ad->ad_rfp->adf_lockcount > 0) &&
!(obj->options.flags & OPTION_CLOSE_STALE_RLOCKS)) {
/* Safety check 3: read-only locks present, but "close stale
* rlocks" is disabled — fall back to conservative behavior. */
LOG(log_warning, logtype_afpd,
"of_close_stale_forks: fork refnum:%u — adouble holds "
"read locks (data:%d rsrc:%d), skipping "
"(close stale rlocks = no)",
of->of_refnum,
of->of_ad->ad_data_fork.adf_lockcount,
of->of_ad->ad_rfp->adf_lockcount);
active_remaining++;
} else {
/* Fork is safe to force-close:
* - Not dirty, no write locks
* - Either no locks at all, or only read locks with
* "close stale rlocks" enabled */
LOG(log_warning, logtype_afpd,
"of_close_stale_forks: force-closing stale fork refnum:%u "
"name:\"%s\" flags:0x%x access:%s "
"(not dirty, no write locks, rdlocks data:%d rsrc:%d)",
of->of_refnum, of_name(of), of->of_flags,
(of->of_flags & AFPFORK_ACCWR) ? "write" :
(of->of_flags & AFPFORK_ACCRD) ? "read" : "none",
of->of_ad->ad_data_fork.adf_lockcount,
of->of_ad->ad_rfp->adf_lockcount);
/* Note: of_closefork() may emit FCE_FILE_MODIFY for MODIFIED
* stale forks. This is harmless — the subsequent deletefile()
* emits FCE_FILE_DELETE, and well-behaved FCE consumers handle
* MODIFY→DELETE sequences correctly.
Comment thread
andylemin marked this conversation as resolved.
*
* The zero-length resource fork check in of_closefork() may
* also unlink the AppleDouble file early. This is safe because
* the delete path handles ENOENT gracefully. */
of_closefork(obj, of);
}

of = next;
}

return active_remaining ? -1 : 0;
}

struct adouble *of_ad(const struct vol *vol, struct path *path,
struct adouble *ad)
{
Expand All @@ -692,19 +805,30 @@ struct adouble *of_ad(const struct vol *vol, struct path *path,
void of_closevol(const AFPObj *obj, const struct vol *vol)
{
int refnum;
int closed_count = 0;

if (!oforks) {
return;
}

for (refnum = 0; refnum < nforks; refnum++) {
if (oforks[refnum] != NULL && oforks[refnum]->of_vol == vol) {
closed_count++;

if (of_closefork(obj, oforks[refnum]) < 0) {
LOG(log_error, logtype_afpd, "of_closevol: %s", strerror(errno));
}
}
}

if (closed_count > 0) {
LOG(log_info, logtype_afpd,
"Closing Volume: closed %d open fork(s) for user \"%s\" "
"vol \"%s\"",
closed_count, obj->username,
vol->v_localname ? vol->v_localname : "unknown");
}

return;
}

Expand All @@ -714,18 +838,27 @@ void of_closevol(const AFPObj *obj, const struct vol *vol)
void of_close_all_forks(const AFPObj *obj)
{
int refnum;
int closed_count = 0;

if (!oforks) {
return;
}

for (refnum = 0; refnum < nforks; refnum++) {
if (oforks[refnum] != NULL) {
closed_count++;

if (of_closefork(obj, oforks[refnum]) < 0) {
LOG(log_error, logtype_afpd, "of_close_all_forks: %s", strerror(errno));
}
}
}

if (closed_count > 0) {
LOG(log_info, logtype_afpd,
"Session shutdown for \"%s\": closed %d open fork(s)",
obj->username, closed_count);
}

return;
}
1 change: 1 addition & 0 deletions include/atalk/adouble.h
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ extern int ad_lock(struct adouble *, uint32_t eid, int type, off_t off,
extern void ad_unlock(struct adouble *, int fork, int unlckbrl);
extern void adf_lock_init(struct ad_fd *adf);
extern void adf_lock_free(struct ad_fd *adf);
extern int adf_has_wrlocks(const struct ad_fd *adf);
extern int ad_tmplock(struct adouble *, uint32_t eid, int type, off_t off,
off_t len, int fork);

Expand Down
1 change: 1 addition & 0 deletions include/atalk/globals.h
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
#define OPTION_SPOTLIGHT_EXPR (1 << 16) /*!< whether to allow Spotlight logic expressions */
#define OPTION_DDP (1 << 17) /*!< whether to allow connections via appletalk/ddp */
#define OPTION_VALID_SHELLCHECK (1 << 18) /*!< whether to check for valid login shell */
#define OPTION_CLOSE_STALE_RLOCKS (1 << 19) /*!< whether to force-close read-locked stale forks on delete */

#define PASSWD_NONE 0
#define PASSWD_SET (1 << 0)
Expand Down
26 changes: 26 additions & 0 deletions libatalk/adouble/ad_lock.c
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,32 @@ void adf_lock_init(struct ad_fd *adf)
adf->adf_lock = NULL;
}

/*!
* @brief Check if an ad_fd holds any write (exclusive) locks
*
* Scans the tracked lock array for F_WRLCK entries. Read locks (F_RDLCK)
* are not counted. Used by of_close_stale_forks() to distinguish between
* read-only locked forks (safe to force-close on delete) and write-locked
* forks (potentially mid-write, must block).
*
* @param[in] adf File descriptor structure to inspect
* @return 1 if at least one write lock exists, 0 otherwise
*/
int adf_has_wrlocks(const struct ad_fd *adf)
{
if (!adf || adf->adf_lockcount == 0) {
return 0;
}

for (int i = 0; i < adf->adf_lockcount; i++) {
if (adf->adf_lock[i].lock.l_type == F_WRLCK) {
return 1;
}
}

return 0;
}

/*!
* @brief Free all locks in an ad_fd structure
*
Expand Down
4 changes: 4 additions & 0 deletions libatalk/util/netatalk_conf.c
Original file line number Diff line number Diff line change
Expand Up @@ -2604,6 +2604,10 @@ int afp_config_parse(AFPObj *AFPObj, char *processname)
options->flags |= OPTION_AFP_READ_LOCK;
}

if (getoption_bool(config, INISEC_GLOBAL, "close stale rlocks", NULL, 0)) {
options->flags |= OPTION_CLOSE_STALE_RLOCKS;
}

if (getoption_bool(config, INISEC_GLOBAL, "spotlight", NULL, 0)) {
options->flags |= OPTION_SPOTLIGHT_VOL;
}
Expand Down
Loading