diff --git a/README.md b/README.md index 2a835c3..7fae3c5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Turn any git repository into your terminal notebook. - `zemo ls` — list topic names from `/topics/` (alphabetical). - `zemo cat [topic]` — print scratch (or `topics/.md`) to stdout. Read-only, no git operations. - `zemo dump` — print scratch and all topics to stdout with section headers. Read-only, no git operations. -- `zemo upgrade` — download and install the latest release in-place. `--check` to dry-run (Unix only for now; Windows tracked in #11). +- `zemo upgrade` — download and install the latest release in-place. `--check` to dry-run. - Auto pull-on-open / commit-on-close when `/.git` exists. - Conventional Commits messages: `docs(): YYYY-MM-DD HH:MM` for edits, `chore: sync ...` for manual sync. - Single static binary on Linux / macOS / Windows. diff --git a/src/cli.zig b/src/cli.zig index 54feefe..a4eab95 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -485,20 +485,29 @@ fn doUpgrade(allocator: std.mem.Allocator, io: Io, stdout: *Io.Writer, stderr: * }; defer allocator.free(archive); - // 3. アーカイブから zemo バイナリを抽出 - const new_binary = upgrade.extractZemoBinary(allocator, archive, upgrade.binaryBasename()) catch |err| { - try stderr.print("zemo: failed to extract binary: {s}\n", .{@errorName(err)}); - return 1; - }; - defer allocator.free(new_binary); - - // 4. 自分自身のパスを取得して置換 + // 3. 先に install_path を解決 (zip 抽出の作業領域として親ディレクトリを使う) const install_path = upgrade.installPath(allocator, io) catch |err| { try stderr.print("zemo: failed to resolve install path: {s}\n", .{@errorName(err)}); return 1; }; defer allocator.free(install_path); + // 4. アーカイブ形式に応じてバイナリを取り出す + const new_binary = if (std.mem.endsWith(u8, asset, ".zip")) blk: { + const work_dir = std.fs.path.dirname(install_path) orelse { + try stderr.print("zemo: cannot determine working dir from install path: {s}\n", .{install_path}); + return 1; + }; + break :blk upgrade.extractZemoBinaryFromZip(allocator, io, archive, upgrade.binaryBasename(), work_dir) catch |err| { + try stderr.print("zemo: failed to extract binary: {s}\n", .{@errorName(err)}); + return 1; + }; + } else upgrade.extractZemoBinary(allocator, archive, upgrade.binaryBasename()) catch |err| { + try stderr.print("zemo: failed to extract binary: {s}\n", .{@errorName(err)}); + return 1; + }; + defer allocator.free(new_binary); + upgrade.replaceBinary(allocator, io, install_path, new_binary) catch |err| { try stderr.print("zemo: failed to replace binary at {s}: {s}\n", .{ install_path, @errorName(err) }); return 1; diff --git a/src/main.zig b/src/main.zig index 3230742..3fa8119 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,8 +1,16 @@ const std = @import("std"); -const cli = @import("zemo").cli; +const zemo = @import("zemo"); +const cli = zemo.cli; +const upgrade = zemo.upgrade; pub fn main(init: std.process.Init) !void { const arena = init.arena.allocator(); + + // Windows で前回の `zemo upgrade` が残した `.old` を起動時に掃除する。 + // 走行中の自分自身を置換した直後はこのプロセス側がまだ .old を握っているので、 + // クリーンアップできるのは「次回以降の zemo 起動」になる。 + upgrade.cleanupStaleBinary(arena, init.io); + const args = try init.minimal.args.toSlice(arena); const code = try cli.run(arena, init.io, init.environ_map, args); std.process.exit(code); diff --git a/src/upgrade.zig b/src/upgrade.zig index b827315..227125b 100644 --- a/src/upgrade.zig +++ b/src/upgrade.zig @@ -37,10 +37,10 @@ pub fn assetName() AssetError![]const u8 { .aarch64 => "zemo-aarch64-macos.tar.gz", else => error.UnsupportedTarget, }, - // Windows support deferred to #11 — needs zip extraction and the - // running-.exe self-replacement workaround. Returning UnsupportedTarget - // here makes `zemo upgrade` fail fast instead of hitting confusing - // gzip errors when fed the .zip asset. + .windows => switch (builtin.cpu.arch) { + .x86_64 => "zemo-x86_64-windows.zip", + else => error.UnsupportedTarget, + }, else => error.UnsupportedTarget, }; } @@ -118,6 +118,10 @@ test "assetName: returns asset for current target" { .aarch64 => "zemo-aarch64-macos.tar.gz", else => return error.SkipZigTest, }, + .windows => switch (builtin.cpu.arch) { + .x86_64 => "zemo-x86_64-windows.zip", + else => return error.SkipZigTest, + }, else => return error.SkipZigTest, }; try std.testing.expectEqualStrings(expected, try assetName()); @@ -130,11 +134,6 @@ test "assetName: rejects musl Linux to avoid replacing musl with glibc binary" { try std.testing.expectError(error.UnsupportedTarget, assetName()); } -test "assetName: rejects Windows until #11 lands zip extraction" { - if (builtin.os.tag != .windows) return error.SkipZigTest; - try std.testing.expectError(error.UnsupportedTarget, assetName()); -} - test "compareVersions: equal" { try std.testing.expectEqual(Comparison.equal, compareVersions("0.2.0", "0.2.0")); } @@ -251,11 +250,108 @@ pub fn extractZemoBinary( return error.BinaryNotFoundInArchive; } +/// zip アーカイブから basename 一致のエントリを抽出してメモリに返す。 +/// std.zip.Iterator が *File.Reader を要求するため、archive bytes を +/// `work_dir_abs` 配下の一時ファイルに書き出してから読み戻す。 +pub fn extractZemoBinaryFromZip( + allocator: std.mem.Allocator, + io: std.Io, + archive: []const u8, + binary_basename: []const u8, // "zemo.exe" + work_dir_abs: []const u8, // 一時ファイルを置く書き込み可能ディレクトリ +) ![]u8 { + // 1. archive bytes を一時ファイルへ書き出す + const temp_zip_path = try std.fs.path.join( + allocator, + &.{ work_dir_abs, ".zemo-upgrade.zip.tmp" }, + ); + defer allocator.free(temp_zip_path); + { + const f = try std.Io.Dir.createFileAbsolute(io, temp_zip_path, .{}); + defer f.close(io); + var w_buf: [4096]u8 = undefined; + var w = f.writer(io, &w_buf); + try w.interface.writeAll(archive); + try w.interface.flush(); + } + defer std.Io.Dir.deleteFileAbsolute(io, temp_zip_path) catch {}; + + // 2. 一時ファイルを File.Reader として開く + var zf = try std.Io.Dir.openFileAbsolute(io, temp_zip_path, .{ .mode = .read_only }); + defer zf.close(io); + var read_buf: [4096]u8 = undefined; + var fr = zf.reader(io, &read_buf); + + // 3. central directory を走査して対象エントリを探す + var iter = try std.zip.Iterator.init(&fr); + var filename_buf: [std.fs.max_path_bytes]u8 = undefined; + while (try iter.next()) |entry| { + if (entry.filename_len == 0 or entry.filename_len > filename_buf.len) continue; + const filename = filename_buf[0..entry.filename_len]; + try fr.seekTo(entry.header_zip_offset + @sizeOf(std.zip.CentralDirectoryFileHeader)); + try fr.interface.readSliceAll(filename); + + if (filename[filename.len - 1] == '/') continue; // ディレクトリエントリ + if (!std.mem.eql(u8, std.fs.path.basename(filename), binary_basename)) continue; + + return try decompressZipEntry(allocator, &fr, entry); + } + + return error.BinaryNotFoundInArchive; +} + +/// zip エントリ 1 件をメモリ上に展開する。 +/// std.zip.Iterator.Entry.extract() が File に書き出す挙動を、メモリ向けに置き換えたもの。 +fn decompressZipEntry( + allocator: std.mem.Allocator, + fr: *std.Io.File.Reader, + entry: std.zip.Iterator.Entry, +) ![]u8 { + // local file header からデータ開始位置を計算 + try fr.seekTo(entry.file_offset); + const local_header = try fr.interface.takeStruct(std.zip.LocalFileHeader, .little); + if (!std.mem.eql(u8, &local_header.signature, &std.zip.local_file_header_sig)) + return error.ZipBadFileOffset; + + const local_data_offset: u64 = entry.file_offset + + @sizeOf(std.zip.LocalFileHeader) + + local_header.filename_len + + local_header.extra_len; + try fr.seekTo(local_data_offset); + + var output = std.Io.Writer.Allocating.init(allocator); + errdefer output.deinit(); + + switch (entry.compression_method) { + .store => try fr.interface.streamExact64(&output.writer, entry.uncompressed_size), + .deflate => { + var flate_buf: [std.compress.flate.max_window_len]u8 = undefined; + var decompress = std.compress.flate.Decompress.init(&fr.interface, .raw, &flate_buf); + try decompress.reader.streamExact64(&output.writer, entry.uncompressed_size); + }, + else => return error.UnsupportedCompressionMethod, + } + + return output.toOwnedSlice(); +} + pub fn replaceBinary( allocator: std.mem.Allocator, io: std.Io, install_path: []const u8, new_binary: []const u8, +) !void { + if (builtin.os.tag == .windows) { + return replaceBinaryWindows(allocator, io, install_path, new_binary); + } + return replaceBinaryPosix(allocator, io, install_path, new_binary); +} + +fn replaceBinaryPosix( + allocator: std.mem.Allocator, + io: std.Io, + install_path: []const u8, + new_binary: []const u8, ) !void { // 1. sibling temp path ".new" const temp_path = try std.fmt.allocPrint(allocator, "{s}.new", .{install_path}); @@ -280,6 +376,83 @@ pub fn replaceBinary( try std.Io.Dir.renameAbsolute(temp_path, install_path, io); } +/// Windows では走行中の `.exe` を削除できないため、以下の手順で置換する: +/// 1. 新バイナリを `.new` に書き出す +/// 2. `` を `.old` に rename (走行中でも可) +/// 3. `.new` を `` に rename +/// 残った `.old` は次回起動時に `cleanupStaleBinary` で削除する。 +fn replaceBinaryWindows( + allocator: std.mem.Allocator, + io: std.Io, + install_path: []const u8, + new_binary: []const u8, +) !void { + const new_path = try std.fmt.allocPrint(allocator, "{s}.new", .{install_path}); + defer allocator.free(new_path); + const old_path = try std.fmt.allocPrint(allocator, "{s}.old", .{install_path}); + defer allocator.free(old_path); + + // 古い .new / .old が残っていた場合は無視できる範囲で掃除 + std.Io.Dir.deleteFileAbsolute(io, new_path) catch {}; + + // 1. 新バイナリを .new に書く + { + const f = try std.Io.Dir.createFileAbsolute(io, new_path, .{}); + defer f.close(io); + var buf: [4096]u8 = undefined; + var w = f.writer(io, &buf); + try w.interface.writeAll(new_binary); + try w.interface.flush(); + } + errdefer std.Io.Dir.deleteFileAbsolute(io, new_path) catch {}; + + // 2. 走行中の .old へ退避 (REPLACE_EXISTING) + try moveFileReplaceExistingWindows(allocator, install_path, old_path); + + // 3. .new を へ昇格 (REPLACE_EXISTING; race 対策) + moveFileReplaceExistingWindows(allocator, new_path, install_path) catch |err| { + // ロールバック: .old を install_path へ戻す + moveFileReplaceExistingWindows(allocator, old_path, install_path) catch {}; + return err; + }; +} + +const MOVEFILE_REPLACE_EXISTING: u32 = 0x1; + +extern "kernel32" fn MoveFileExW( + lpExistingFileName: ?[*:0]const u16, + lpNewFileName: ?[*:0]const u16, + dwFlags: u32, +) callconv(.winapi) std.os.windows.BOOL; + +fn moveFileReplaceExistingWindows( + allocator: std.mem.Allocator, + src_wtf8: []const u8, + dst_wtf8: []const u8, +) !void { + const src_w = std.unicode.wtf8ToWtf16LeAllocZ(allocator, src_wtf8) catch + return error.InvalidPath; + defer allocator.free(src_w); + const dst_w = std.unicode.wtf8ToWtf16LeAllocZ(allocator, dst_wtf8) catch + return error.InvalidPath; + defer allocator.free(dst_w); + + if (!MoveFileExW(src_w.ptr, dst_w.ptr, MOVEFILE_REPLACE_EXISTING).toBool()) { + return error.MoveFileFailed; + } +} + +/// Windows: 直前の `zemo upgrade` が残した `.old` をベストエフォートで削除する。 +/// 他プラットフォームでは何もしない。失敗しても無視する。 +pub fn cleanupStaleBinary(allocator: std.mem.Allocator, io: std.Io) void { + if (builtin.os.tag != .windows) return; + const exe_path = std.process.executablePathAlloc(io, allocator) catch return; + defer allocator.free(exe_path); + const old_path = std.fmt.allocPrint(allocator, "{s}.old", .{exe_path}) catch return; + defer allocator.free(old_path); + std.Io.Dir.deleteFileAbsolute(io, old_path) catch {}; +} + test "parseReleaseJson: invalid JSON returns error" { try std.testing.expectError(error.SyntaxError, parseReleaseJson(std.testing.allocator, "not a json")); } @@ -310,6 +483,112 @@ test "installPath: returns absolute non-empty path" { try std.testing.expect(std.fs.path.isAbsolute(path)); } +/// テスト用: 1 ファイルだけを格納した最小 zip を組み立てる (compression: store)。 +fn buildStoredZip( + allocator: std.mem.Allocator, + filename: []const u8, + contents: []const u8, +) ![]u8 { + var buf = std.Io.Writer.Allocating.init(allocator); + errdefer buf.deinit(); + const w = &buf.writer; + + const crc = std.hash.Crc32.hash(contents); + const fname_len: u16 = @intCast(filename.len); + const csize: u32 = @intCast(contents.len); + const usize_: u32 = @intCast(contents.len); + const lfh_offset: u32 = 0; + + // Local File Header + try w.writeAll("PK\x03\x04"); + try w.writeInt(u16, 20, .little); // version needed + try w.writeInt(u16, 0, .little); // flags + try w.writeInt(u16, 0, .little); // method = store + try w.writeInt(u16, 0, .little); // mod time + try w.writeInt(u16, 0x21, .little); // mod date (1980-01-01) + try w.writeInt(u32, crc, .little); + try w.writeInt(u32, csize, .little); + try w.writeInt(u32, usize_, .little); + try w.writeInt(u16, fname_len, .little); + try w.writeInt(u16, 0, .little); // extra len + try w.writeAll(filename); + try w.writeAll(contents); + + const cd_offset: u32 = @intCast(buf.written().len); + + // Central Directory File Header + try w.writeAll("PK\x01\x02"); + try w.writeInt(u16, 20, .little); // version made by + try w.writeInt(u16, 20, .little); // version needed + try w.writeInt(u16, 0, .little); // flags + try w.writeInt(u16, 0, .little); // method + try w.writeInt(u16, 0, .little); // mod time + try w.writeInt(u16, 0x21, .little); // mod date + try w.writeInt(u32, crc, .little); + try w.writeInt(u32, csize, .little); + try w.writeInt(u32, usize_, .little); + try w.writeInt(u16, fname_len, .little); + try w.writeInt(u16, 0, .little); // extra len + try w.writeInt(u16, 0, .little); // comment len + try w.writeInt(u16, 0, .little); // disk number + try w.writeInt(u16, 0, .little); // internal attrs + try w.writeInt(u32, 0, .little); // external attrs + try w.writeInt(u32, lfh_offset, .little); + try w.writeAll(filename); + + const cd_size: u32 = @intCast(buf.written().len - cd_offset); + + // End of Central Directory + try w.writeAll("PK\x05\x06"); + try w.writeInt(u16, 0, .little); // disk + try w.writeInt(u16, 0, .little); // disk with CD start + try w.writeInt(u16, 1, .little); // entries this disk + try w.writeInt(u16, 1, .little); // total entries + try w.writeInt(u32, cd_size, .little); + try w.writeInt(u32, cd_offset, .little); + try w.writeInt(u16, 0, .little); // comment len + + return buf.toOwnedSlice(); +} + +test "extractZemoBinaryFromZip: extracts stored entry by basename" { + const io = std.testing.io; + const a = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const archive = try buildStoredZip(a, "zemo-x86_64-windows/zemo.exe", "MZHELLO"); + defer a.free(archive); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const len = try tmp.dir.realPath(io, &path_buf); + const work_dir = path_buf[0..len]; + + const out = try extractZemoBinaryFromZip(a, io, archive, "zemo.exe", work_dir); + defer a.free(out); + + try std.testing.expectEqualStrings("MZHELLO", out); +} + +test "extractZemoBinaryFromZip: returns error when basename not in archive" { + const io = std.testing.io; + const a = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const archive = try buildStoredZip(a, "other.txt", "irrelevant"); + defer a.free(archive); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const len = try tmp.dir.realPath(io, &path_buf); + const work_dir = path_buf[0..len]; + + try std.testing.expectError( + error.BinaryNotFoundInArchive, + extractZemoBinaryFromZip(a, io, archive, "zemo.exe", work_dir), + ); +} + test "replaceBinary: replaces existing file with new contents" { const io = std.testing.io; const a = std.testing.allocator;