fix(skills): create shell-published links at the destination atomically
The POSIX shell installers staged shared Skill links and published them with mv after an existence check; a file or symlink another process created at the destination between the check and the move was silently replaced, and the inode confirmation could not detect the loss. Publish by creating each link directly at its destination instead — symlink(2) refuses an occupied path with EEXIST, so the creation itself is the atomic no-replace check. A directory that appears at the destination turns ln -s into a container; the nested link is removed after an identity check and the transaction rolls back, leaving the foreign directory untouched. Applied to install.sh, install-skills.sh, install-event.sh, and install-devapp.sh. Also covers the remaining retraction branches of the Go shell-removal fallback so changed-code coverage is complete. Regression tests inject a concurrent occupant at the publish instant for regular-file and directory cases and assert the foreign object and its contents stay completely unchanged.
This commit is contained in:
@@ -244,10 +244,26 @@ link_or_copy_skill() {
|
||||
return 1
|
||||
fi
|
||||
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
||||
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
|
||||
# Create the link directly at the destination: symlink(2) refuses an
|
||||
# occupied path with EEXIST, so publication itself is the atomic no-replace
|
||||
# check (`mv` after the backup could still replace a file or symlink a
|
||||
# concurrent writer created in between). A directory that slipped in turns
|
||||
# `ln -s` into a container: remove exactly our nested link and roll back.
|
||||
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
|
||||
rm -rf "$stage"
|
||||
return 0
|
||||
fi
|
||||
nested="$dest/${relative##*/}"
|
||||
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
|
||||
rm -f "$nested" || true
|
||||
fi
|
||||
rm -rf "$stage"
|
||||
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
if [ -n "$backup" ]; then
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
|
||||
elif ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -313,10 +313,26 @@ link_or_copy_skill() {
|
||||
return 1
|
||||
fi
|
||||
backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
|
||||
if mv "$stage/skill" "$dest"; then rm -rf "$stage"; return 0; fi
|
||||
# Create the link directly at the destination: symlink(2) refuses an
|
||||
# occupied path with EEXIST, so publication itself is the atomic no-replace
|
||||
# check (`mv` after the backup could still replace a file or symlink a
|
||||
# concurrent writer created in between). A directory that slipped in turns
|
||||
# `ln -s` into a container: remove exactly our nested link and roll back.
|
||||
if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
|
||||
rm -rf "$stage"
|
||||
return 0
|
||||
fi
|
||||
nested="$dest/${relative##*/}"
|
||||
if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
|
||||
rm -f "$nested" || true
|
||||
fi
|
||||
rm -rf "$stage"
|
||||
if [ -n "$backup" ] && ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
if [ -n "$backup" ]; then
|
||||
if [ -e "$dest" ] || [ -L "$dest" ]; then
|
||||
printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
|
||||
elif ! mv "$backup" "$dest"; then
|
||||
printf ' ❌ Skill rollback failed; backup retained at %s\n' "$backup" >&2
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
+21
-17
@@ -720,25 +720,29 @@ link_canonical_skills_to_base() {
|
||||
_lcs_link_target="$(readlink "$_lcs_staged" 2>/dev/null)" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
}
|
||||
# The staged symlink keeps its inode across the rename below (the staging
|
||||
# directory is a sibling inside the same Agent root), so the identity
|
||||
# recorded in the publication manifest stays exact.
|
||||
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
}
|
||||
# Anything still present here appeared after the pre-flight scan, i.e. a
|
||||
# concurrent writer. Never let `mv` clobber it.
|
||||
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
|
||||
# Publish by creating the link directly at the destination: symlink(2)
|
||||
# refuses an occupied path with EEXIST, so the creation itself is the
|
||||
# atomic no-replace check. `mv` after any pre-check could still replace a
|
||||
# file or symlink a concurrent writer created in between, and `ln -P`
|
||||
# stays unusable (absent from BusyBox `ln`, which silently degraded every
|
||||
# non-universal Agent to the copy layout on Alpine and most containers).
|
||||
if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
fi
|
||||
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
|
||||
# absent from BusyBox `ln`, which silently degraded every non-universal
|
||||
# Agent to the copy layout on Alpine and most containers). install-event.sh
|
||||
# and install-devapp.sh already publish this way. The staged basename stays
|
||||
# unique, so a destination a concurrent process turned into a directory can
|
||||
# only receive our link and post-validation removes exactly that.
|
||||
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
|
||||
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
|
||||
# A directory that appeared at the destination turns `ln -s` into a
|
||||
# container: the link lands inside it under the target basename. Remove
|
||||
# exactly our link after an identity check and roll back; the foreign
|
||||
# directory stays untouched.
|
||||
if [ ! -L "$_lcs_dest" ]; then
|
||||
_lcs_nested_name="${_lcs_link_target##*/}"
|
||||
_lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
|
||||
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
fi
|
||||
_lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
}
|
||||
if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
|
||||
fi
|
||||
if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
|
||||
|
||||
+25
-19
@@ -961,29 +961,35 @@ link_canonical_skills_to_base() {
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
}
|
||||
# The staged symlink keeps its inode across the rename below (the staging
|
||||
# directory is a sibling inside the same Agent root), so the identity
|
||||
# recorded in the publication manifest stays exact.
|
||||
_lcs_inode="$(skill_link_inode "$_lcs_staged")" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
}
|
||||
# Anything still present here appeared after the pre-flight scan, i.e. a
|
||||
# concurrent writer. Never let `mv` clobber it.
|
||||
if [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; then
|
||||
# Publish by creating the link directly at the destination: symlink(2)
|
||||
# refuses an occupied path with EEXIST, so the creation itself is the
|
||||
# atomic no-replace check. `mv` after any pre-check could still replace a
|
||||
# file or symlink a concurrent writer created in between, and `ln -P`
|
||||
# stays unusable (absent from BusyBox `ln`, which silently degraded every
|
||||
# non-universal Agent to the copy layout on Alpine and most containers).
|
||||
if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
fi
|
||||
# Publish by moving the staged symlink. `mv` is POSIX; `ln -P` is not (it is
|
||||
# absent from BusyBox `ln`, which silently degraded every non-universal
|
||||
# Agent to the copy layout on Alpine and most containers). install-event.sh
|
||||
# and install-devapp.sh already publish this way. The staged basename stays
|
||||
# unique, so a destination a concurrent process turned into a directory can
|
||||
# only receive our link and post-validation removes exactly that.
|
||||
if ! mv "$_lcs_staged" "$_lcs_dest" 2>/dev/null || ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
|
||||
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_stage_name" "$_lcs_link_target" "$_lcs_inode" || true
|
||||
# A directory that appeared at the destination turns `ln -s` into a
|
||||
# container: the link lands inside it under the target basename. Remove
|
||||
# exactly our link after an identity check and roll back; the foreign
|
||||
# directory stays untouched.
|
||||
if [ ! -L "$_lcs_dest" ]; then
|
||||
_lcs_nested_name="${_lcs_link_target##*/}"
|
||||
_lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
|
||||
cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
fi
|
||||
_lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
}
|
||||
if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
|
||||
restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
|
||||
rm -rf "$_lcs_stage"
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user