fix(update): keep read-only official plugin files during extract (#595)

* fix(update): keep read-only official plugin files during extract

Docker Desktop returns EPERM when chmod runs again on an official plugin file that tar already unpacked as mode 0444.

Fixes #594

* test(update): cover writable official plugin archives

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: le-czs <243511553+le-czs@users.noreply.github.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
le-czs
2026-11-04 13:09:57 +08:00
committed by GitHub
co-authored by Codex le-czs Zyy
parent aa003f7d90
commit 28eb4708b5
3 changed files with 44 additions and 14 deletions
@@ -86,11 +86,7 @@ pub(crate) fn extract_release(
.unpack(&target)
.map_err(|error| internal("无法解压官方插件文件").with_source(error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&target, fs::Permissions::from_mode(0o444))
.map_err(|error| internal("无法设置官方插件文件权限").with_source(error))?;
}
ensure_official_plugin_file_readonly(&target)?;
found_official_manifest |= relative == Path::new("plugin-release-manifest.json");
continue;
}
@@ -128,6 +124,24 @@ pub(crate) fn extract_release(
})
}
#[cfg(unix)]
fn ensure_official_plugin_file_readonly(path: &Path) -> Result<(), OperationError> {
use std::os::unix::fs::PermissionsExt as _;
// tar 解包已对打开的文件描述符套用归档模式
// Docker Desktop 把 macOS 目录挂进容器后,0444 文件再次 chmod 会返回 EPERM,权限已满足时不必再改
let current_mode = fs::symlink_metadata(path)
.map_err(|error| internal("无法设置官方插件文件权限").with_source(error))?
.permissions()
.mode()
& 0o777;
if current_mode == 0o444 {
return Ok(());
}
fs::set_permissions(path, fs::Permissions::from_mode(0o444))
.map_err(|error| internal("无法设置官方插件文件权限").with_source(error))
}
fn unsafe_archive_path(path: &Path) -> bool {
path.is_absolute()
|| path.components().any(|component| match component {
@@ -419,13 +419,14 @@ enum ChecksumKind {
fn release_archive(kind: ArchiveKind) -> Vec<u8> {
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = Builder::new(encoder);
append_file(&mut tar, BINARY_NAME, b"new-binary", false);
append_file(&mut tar, "web/dist/index.html", b"new-web", false);
append_file(&mut tar, BINARY_NAME, b"new-binary", 0o755, false);
append_file(&mut tar, "web/dist/index.html", b"new-web", 0o755, false);
if !matches!(kind, ArchiveKind::MissingOfficialManifest) {
append_file(
&mut tar,
"plugins/official/plugin-release-manifest.json",
b"new-manifest",
0o444,
false,
);
}
@@ -433,16 +434,18 @@ fn release_archive(kind: ArchiveKind) -> Vec<u8> {
&mut tar,
"plugins/official/new-plugin.tar.gz",
b"new-plugin",
0o644,
false,
);
if let ArchiveKind::UnsafePath(name) = kind {
append_file(&mut tar, name, b"escape", true);
append_file(&mut tar, name, b"escape", 0o755, true);
}
if matches!(kind, ArchiveKind::NestedOfficialAsset) {
append_file(
&mut tar,
"plugins/official/nested/plugin.tar.gz",
b"nested",
0o444,
false,
);
}
@@ -468,14 +471,16 @@ fn append_symlink(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, target: &st
tar.append(&header, std::io::empty()).expect("append link");
}
fn append_file(tar: &mut Builder<GzEncoder<Vec<u8>>>, name: &str, data: &[u8], unsafe_path: bool) {
fn append_file(
tar: &mut Builder<GzEncoder<Vec<u8>>>,
name: &str,
data: &[u8],
mode: u32,
unsafe_path: bool,
) {
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_mode(if name.starts_with("plugins/official/") {
0o444
} else {
0o755
});
header.set_mode(mode);
header.set_size(u64::try_from(data.len()).expect("size"));
header
.set_path(if unsafe_path { "safe" } else { name })
@@ -471,6 +471,17 @@ async fn rollback_should_restore_binary_web_and_version_state() {
& 0o777,
0o555
);
for name in ["plugin-release-manifest.json", "new-plugin.tar.gz"] {
assert_eq!(
fs::metadata(fixture.official().join(name))
.expect("official plugin file")
.permissions()
.mode()
& 0o777,
0o444,
"{name}"
);
}
}
service
.rollback(Arc::new(AllowingUpdatePreflight))