M8: initrd handoff + multi-binary shipping
Ship more than one user binary: the bootloader now ferries an initrd bundle
(the VFS server + future drivers) alongside sbin/init, and the kernel unpacks
and spawns each program.
- src/user/proto/initrd.zig: the container format (Header{magic,count} +
Entry{name[32],offset,len} + blobs) with a validating Reader, shared by the
kernel and the packer.
- tools/mkinitrd.py: host-side packer (Python — trivial format, and sidesteps
the reworked Zig 0.16 std fs/args API). build.zig runs it on the built user
binaries via addSystemCommand and installs initrd.img to zig-out/bin + the
ESP root.
- BootInfo gains initrd_base/initrd_len; efi.zig loadInit refactored into a
shared loadFile, and loadInitrd ferries \initrd.img into surviving
LoaderData like init.
- main.zig startInitrdBinaries(): parse the image, spawn every entry (kernel-
spawns-all for now; init takes over via sys_spawn later).
- sbin/vfs.zig: a heartbeat stub (the real VFS server is M9), packed into the
initrd to prove the pipeline.
- New `initrd` test: parse + spawn + confirm the vfs stub reaches ring 3 and
heartbeats. Harness ships initrd.img on the ESP. Suite 30/30.
This commit is contained in:
parent
eabb81684a
commit
750a73f050
30
build.zig
30
build.zig
|
|
@ -147,6 +147,12 @@ pub fn build(b: *std.Build) void {
|
|||
},
|
||||
});
|
||||
|
||||
// The initrd container format, shared by the kernel (unpacks it) and the
|
||||
// build-time packer tools/mkinitrd.zig (produces it). No dependencies.
|
||||
const initrd_mod = b.addModule("initrd", .{
|
||||
.root_source_file = b.path("src/user/proto/initrd.zig"),
|
||||
});
|
||||
|
||||
// Compile-time config the kernel reads as `@import("build_options")`. The
|
||||
// QEMU test harness sets -Dtest-case=<name> to run one self-test at boot.
|
||||
const test_case = b.option([]const u8, "test-case", "Kernel self-test case to run at boot (see src/kernel/tests.zig)");
|
||||
|
|
@ -182,6 +188,7 @@ pub fn build(b: *std.Build) void {
|
|||
.{ .name = "platform", .module = platform_mod },
|
||||
.{ .name = "config", .module = config_mod },
|
||||
.{ .name = "build_options", .module = build_options_mod },
|
||||
.{ .name = "initrd", .module = initrd_mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
@ -206,6 +213,26 @@ pub fn build(b: *std.Build) void {
|
|||
const init_exe = addUserBinary(b, kernel_target, rt_mod, "init", "sbin/init.zig");
|
||||
b.installArtifact(init_exe);
|
||||
|
||||
// --- initrd: a bundle of extra user binaries (VFS server + drivers) ---
|
||||
// Each is built by the same user-binary recipe, then packed into one image by
|
||||
// the host-side mkinitrd tool. The bootloader ferries the image to the kernel,
|
||||
// which unpacks it and spawns each program (src/user/proto/initrd.zig).
|
||||
const vfs_exe = addUserBinary(b, kernel_target, rt_mod, "vfs", "sbin/vfs.zig");
|
||||
|
||||
// Pack the user binaries into the initrd image with the host-side Python tool
|
||||
// (the container format is trivial, and Python sidesteps std API churn). Args:
|
||||
// mkinitrd.py <out> [<name> <file>]... — one name/file pair per binary.
|
||||
const mk_run = b.addSystemCommand(&.{"python3"});
|
||||
mk_run.addFileArg(b.path("tools/mkinitrd.py"));
|
||||
const initrd_img = mk_run.addOutputFileArg("initrd.img");
|
||||
mk_run.addArg("vfs");
|
||||
mk_run.addFileArg(vfs_exe.getEmittedBin());
|
||||
|
||||
// Install the image to zig-out/bin (so the QEMU test harness picks it up like
|
||||
// the other binaries). The run-x86-64 ESP install is added below.
|
||||
const initrd_install = b.addInstallFile(initrd_img, "bin/initrd.img");
|
||||
b.getInstallStep().dependOn(&initrd_install.step);
|
||||
|
||||
// Boot methods live in src/boot/, one per way of getting the kernel running.
|
||||
// Each is its own binary/entry (a loader is built for its own target); today
|
||||
// that's UEFI for x86-64, with room for e.g. a device-tree path for the Pis.
|
||||
|
|
@ -268,6 +295,8 @@ pub fn build(b: *std.Build) void {
|
|||
const init_install = b.addInstallArtifact(init_exe, .{
|
||||
.dest_dir = .{ .override = .{ .custom = "esp/sbin" } },
|
||||
});
|
||||
// ...and the initrd (VFS server + drivers) from the volume root.
|
||||
const initrd_esp_install = b.addInstallFile(initrd_img, "esp/initrd.img");
|
||||
|
||||
// The firmware needs to write NVRAM, so give it a writable copy of the vars.
|
||||
const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars });
|
||||
|
|
@ -305,6 +334,7 @@ pub fn build(b: *std.Build) void {
|
|||
run_efi.step.dependOn(&efi_install.step);
|
||||
run_efi.step.dependOn(&kernel_install.step);
|
||||
run_efi.step.dependOn(&init_install.step);
|
||||
run_efi.step.dependOn(&initrd_esp_install.step);
|
||||
|
||||
const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/run-x86-64-serial0-<timestamp>.log");
|
||||
run_efi_step.dependOn(&run_efi.step);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
//! /sbin/vfs — the user-space VFS server. Shipped in the initrd and spawned as a
|
||||
//! ring-3 process. This is a placeholder that only heartbeats, proving the initrd
|
||||
//! pipeline ships and spawns it; the real path namespace + IPC dispatch loop
|
||||
//! (open/read/write/stat forwarded to driver processes) is built in M9.
|
||||
|
||||
const rt = @import("rt");
|
||||
|
||||
pub fn main() void {
|
||||
while (true) {
|
||||
_ = rt.sys.write("vfs: alive\n");
|
||||
rt.sys.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
pub const panic = rt.panic;
|
||||
comptime {
|
||||
_ = &rt.start._start;
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ const kernel_file_name = std.unicode.utf8ToUtf16LeStringLiteral("kernel");
|
|||
/// the FAT driver walks the components itself, so no directory dance needed).
|
||||
const init_file_name = std.unicode.utf8ToUtf16LeStringLiteral("sbin\\init");
|
||||
|
||||
/// Path of the initrd image on the boot volume (the VFS server + drivers).
|
||||
const initrd_file_name = std.unicode.utf8ToUtf16LeStringLiteral("initrd.img");
|
||||
|
||||
/// Physical page size, and the sentinel UEFI uses to seek to end-of-file.
|
||||
const page_size = 4096;
|
||||
const seek_end = 0xffff_ffff_ffff_ffff;
|
||||
|
|
@ -65,6 +68,13 @@ fn boot() !noreturn {
|
|||
log(") - booting without user space\r\n");
|
||||
};
|
||||
|
||||
// Best effort: the initrd (VFS server + drivers) is optional too.
|
||||
loadInitrd(bs, &boot_info) catch |err| {
|
||||
log("danos: no initrd (");
|
||||
logBytes(@errorName(err));
|
||||
log(")\r\n");
|
||||
};
|
||||
|
||||
// Build the page tables the kernel starts life on: identity + a physmap of
|
||||
// low RAM, plus the higher-half kernel image once it links high. Allocated
|
||||
// now, while boot services (and the memory map) are still stable — nothing
|
||||
|
|
@ -344,12 +354,11 @@ fn handoff(cr3: u64, entry: usize, boot_info: *const BootInfo) noreturn {
|
|||
unreachable;
|
||||
}
|
||||
|
||||
/// Read the init program (sbin/init) into memory that outlives the loader and
|
||||
/// record it in the handoff. The pool buffer is deliberately NOT freed: it's
|
||||
/// LoaderData, which the memory-map conversion classifies as reserved, so the
|
||||
/// kernel identity-maps it and reads the ELF from there. The kernel does the
|
||||
/// loading itself (into ring-3 mappings) — the loader just ferries the bytes.
|
||||
fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
|
||||
/// Read a whole file off the boot volume into a pool buffer that outlives the
|
||||
/// loader. The buffer is deliberately NOT freed: it's LoaderData, which the
|
||||
/// memory-map conversion classifies as reserved, so the kernel identity-maps it
|
||||
/// and reads from there. Returns the buffer (pointer + length).
|
||||
fn loadFile(bs: *uefi.tables.BootServices, name: [*:0]const u16) ![]u8 {
|
||||
const loaded = (try bs.handleProtocol(uefi.protocol.LoadedImage, uefi.handle)) orelse
|
||||
return error.NoLoadedImage;
|
||||
const device = loaded.device_handle orelse return error.NoBootDevice;
|
||||
|
|
@ -359,7 +368,7 @@ fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
|
|||
const root = try fs.openVolume();
|
||||
defer _ = root.close() catch {};
|
||||
|
||||
const file = try root.open(init_file_name, .read, .{});
|
||||
const file = try root.open(name, .read, .{});
|
||||
defer _ = file.close() catch {};
|
||||
|
||||
try file.setPosition(seek_end);
|
||||
|
|
@ -375,12 +384,26 @@ fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
|
|||
if (n == 0) return error.UnexpectedEof;
|
||||
read_total += n;
|
||||
}
|
||||
return image[0..size];
|
||||
}
|
||||
|
||||
/// Ferry the init program (sbin/init) to the kernel. The kernel does the ELF
|
||||
/// loading itself (into ring-3 mappings) — the loader just carries the bytes.
|
||||
fn loadInit(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
|
||||
const image = try loadFile(bs, init_file_name);
|
||||
boot_info.init_base = @intFromPtr(image.ptr);
|
||||
boot_info.init_len = size;
|
||||
boot_info.init_len = image.len;
|
||||
log("danos: sbin/init loaded\r\n");
|
||||
}
|
||||
|
||||
/// Ferry the initrd (the VFS server + drivers) to the kernel, same as init.
|
||||
fn loadInitrd(bs: *uefi.tables.BootServices, boot_info: *BootInfo) !void {
|
||||
const image = try loadFile(bs, initrd_file_name);
|
||||
boot_info.initrd_base = @intFromPtr(image.ptr);
|
||||
boot_info.initrd_len = image.len;
|
||||
log("danos: initrd loaded\r\n");
|
||||
}
|
||||
|
||||
/// Validate the ELF, copy every PT_LOAD segment to its physical address, and
|
||||
/// record each segment's layout so the kernel can re-map itself with the right
|
||||
/// permissions.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const pmm = @import("pmm.zig");
|
|||
const heap = @import("heap.zig");
|
||||
const scheduler = @import("scheduler.zig");
|
||||
const process = @import("process.zig");
|
||||
const initrd = @import("initrd");
|
||||
const platform = @import("platform");
|
||||
const tests = @import("tests.zig");
|
||||
const build_options = @import("build_options");
|
||||
|
|
@ -270,6 +271,11 @@ fn kmain(boot_info: *const BootInfo) noreturn {
|
|||
status("no /sbin/init on the boot volume.\n");
|
||||
}
|
||||
|
||||
// Spawn the extra user binaries the loader ferried in the initrd (the VFS
|
||||
// server, and later device drivers). For now the kernel launches them all;
|
||||
// once init is a real service supervisor it will spawn them itself (sys_spawn).
|
||||
startInitrdBinaries(boot_info);
|
||||
|
||||
// Become the idle task: drop below every real task and halt until an
|
||||
// interrupt. The timer keeps preempting into init and any other work.
|
||||
scheduler.setPriority(0);
|
||||
|
|
@ -277,6 +283,26 @@ fn kmain(boot_info: *const BootInfo) noreturn {
|
|||
arch.halt();
|
||||
}
|
||||
|
||||
/// Spawn every program bundled in the initrd as its own ring-3 process. A bad
|
||||
/// image or a program that fails to load is logged and skipped — the rest of the
|
||||
/// system still runs.
|
||||
fn startInitrdBinaries(boot_info: *const danos.BootInfo) void {
|
||||
if (boot_info.initrd_len == 0) return;
|
||||
const image = @as([*]const u8, @ptrFromInt(danos.physToVirt(boot_info.initrd_base)))[0..boot_info.initrd_len];
|
||||
const rd = initrd.Reader.init(image) orelse {
|
||||
status("initrd: bad image, skipping\n");
|
||||
return;
|
||||
};
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
statusPrint("starting /sbin/{s} (from initrd)...\n", .{item.name});
|
||||
process.spawnProcess(item.blob, 4) catch |err| {
|
||||
statusPrint("initrd: {s} failed to load: {s}\n", .{ item.name, @errorName(err) });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake the application processors the firmware left parked. Allocates the low
|
||||
/// trampoline page (and makes it executable), then wakes each non-boot core in turn,
|
||||
/// handing it a fresh kernel stack and its per-CPU slot. Cores that don't report in
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const sched = @import("scheduler.zig");
|
|||
const ipc = @import("ipc.zig");
|
||||
const ipcsync = @import("ipc_sync.zig");
|
||||
const process = @import("process.zig");
|
||||
const initrd = @import("initrd");
|
||||
|
||||
/// Formatted write straight to serial, independent of the framebuffer console.
|
||||
fn log(comptime fmt: []const u8, args: anytype) void {
|
||||
|
|
@ -104,6 +105,8 @@ pub fn run(case: []const u8, boot_info: *const BootInfo) void {
|
|||
initTest(boot_info);
|
||||
} else if (eql(case, "process")) {
|
||||
processTest(boot_info);
|
||||
} else if (eql(case, "initrd")) {
|
||||
initrdTest(boot_info);
|
||||
} else if (eql(case, "poweroff")) {
|
||||
powerTest(.off);
|
||||
} else if (eql(case, "reboot")) {
|
||||
|
|
@ -936,6 +939,51 @@ fn initTest(boot_info: *const BootInfo) void {
|
|||
result();
|
||||
}
|
||||
|
||||
/// The initrd path: the bootloader handed over an image bundling extra user
|
||||
/// binaries; parse it, spawn every program, and confirm one (the vfs stub)
|
||||
/// reaches ring 3 and heartbeats — proving the whole ferry-parse-spawn pipeline.
|
||||
fn initrdTest(boot_info: *const BootInfo) void {
|
||||
log("DANOS-TEST-BEGIN: initrd\n", .{});
|
||||
check("bootloader handed over an initrd", boot_info.initrd_len != 0);
|
||||
if (boot_info.initrd_len == 0) {
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const image = @as([*]const u8, @ptrFromInt(danos.physToVirt(boot_info.initrd_base)))[0..boot_info.initrd_len];
|
||||
const rd = initrd.Reader.init(image) orelse {
|
||||
check("initrd image is valid", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
check("initrd image is valid", true);
|
||||
check("initrd contains at least one binary", rd.count >= 1);
|
||||
|
||||
process.write_count = 0;
|
||||
process.write_from_user = false;
|
||||
var spawned: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (process.spawnProcess(item.blob, 4)) spawned += 1 else |err| {
|
||||
log("DANOS-INITRD-ERR: {s}: {s}\n", .{ item.name, @errorName(err) });
|
||||
}
|
||||
}
|
||||
check("every initrd binary spawned", spawned == rd.count);
|
||||
|
||||
// Wait for the spawned program(s) to heartbeat (the vfs stub sleeps ~1 s).
|
||||
sched.setPriority(1);
|
||||
const deadline = arch.millis() + 8000;
|
||||
while (process.write_count < 2 and arch.millis() < deadline) sched.yield();
|
||||
sched.setPriority(4);
|
||||
|
||||
const prefix = "vfs: alive";
|
||||
const beat_ok = process.write_len >= prefix.len and eql(process.write_buf[0..prefix.len], prefix);
|
||||
check("an initrd process heartbeats (>=2)", process.write_count >= 2);
|
||||
check("heartbeat text arrived intact", beat_ok);
|
||||
check("heartbeats came from user mode (CPL 3)", process.write_from_user);
|
||||
result();
|
||||
}
|
||||
|
||||
fn faultInvalidOpcode() void {
|
||||
log("DANOS-TEST-BEGIN: fault-ud\n", .{});
|
||||
asm volatile ("ud2");
|
||||
|
|
|
|||
|
|
@ -182,4 +182,9 @@ pub const BootInfo = extern struct {
|
|||
/// kernel boots without user space. Grows into a full initrd handoff later.
|
||||
init_base: u64 = 0,
|
||||
init_len: u64 = 0,
|
||||
/// The initrd image (a bundle of extra user binaries — the VFS server and
|
||||
/// device drivers), read off the boot volume into memory that survives the
|
||||
/// handoff, same as `init` above. 0/0 = no initrd. See src/user/proto/initrd.zig.
|
||||
initrd_base: u64 = 0,
|
||||
initrd_len: u64 = 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
//! The initrd (initial ramdisk) container format — shared by the build-time
|
||||
//! packer (tools/mkinitrd.zig) and the kernel that unpacks it. Deliberately
|
||||
//! trivial: a header, a table of fixed-size entries, then the concatenated file
|
||||
//! blobs. We own both producer and consumer, so it need be no fancier.
|
||||
//!
|
||||
//! Layout:
|
||||
//! Header (magic, count)
|
||||
//! Entry * count (name, offset, len) — offset/len into the image
|
||||
//! blob bytes... (each entry's file, at its offset)
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// "DNRD" — identifies a danos initrd image.
|
||||
pub const magic: u32 = 0x444E5244;
|
||||
|
||||
pub const Header = extern struct {
|
||||
magic: u32,
|
||||
count: u32,
|
||||
};
|
||||
|
||||
pub const Entry = extern struct {
|
||||
name: [32]u8, // NUL-padded file name (basename)
|
||||
offset: u64, // byte offset of the blob within the image
|
||||
len: u64, // blob length in bytes
|
||||
};
|
||||
|
||||
/// A validated view over an initrd image. `init` checks the magic and that the
|
||||
/// entry table fits; `entry` bounds-checks each blob against the image.
|
||||
pub const Reader = struct {
|
||||
image: []const u8,
|
||||
count: u32,
|
||||
|
||||
pub fn init(image: []const u8) ?Reader {
|
||||
if (image.len < @sizeOf(Header)) return null;
|
||||
const h = std.mem.bytesToValue(Header, image[0..@sizeOf(Header)]);
|
||||
if (h.magic != magic) return null;
|
||||
const table_end = @sizeOf(Header) + @as(usize, h.count) * @sizeOf(Entry);
|
||||
if (table_end > image.len) return null;
|
||||
return .{ .image = image, .count = h.count };
|
||||
}
|
||||
|
||||
pub const Item = struct { name: []const u8, blob: []const u8 };
|
||||
|
||||
pub fn entry(self: Reader, i: u32) ?Item {
|
||||
if (i >= self.count) return null;
|
||||
const off = @sizeOf(Header) + @as(usize, i) * @sizeOf(Entry);
|
||||
const e = std.mem.bytesToValue(Entry, self.image[off..][0..@sizeOf(Entry)]);
|
||||
if (e.offset > self.image.len or e.len > self.image.len - e.offset) return null;
|
||||
// The name is stored in the entry's fixed field; return a stable slice
|
||||
// into the image (not the value copy) up to the NUL terminator.
|
||||
const name_field = self.image[off .. off + 32];
|
||||
const nlen = std.mem.indexOfScalar(u8, name_field, 0) orelse name_field.len;
|
||||
return .{
|
||||
.name = name_field[0..nlen],
|
||||
.blob = self.image[@intCast(e.offset)..][0..@intCast(e.len)],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -57,8 +57,9 @@ ARCHES = {
|
|||
],
|
||||
"efi_app": ("EFI/BOOT/BOOTX64.efi", "BOOTX64.efi"), # (dest in ESP, name in zig-out/bin)
|
||||
"kernel": ("kernel", "kernel"),
|
||||
# Further files shipped on the ESP: the init user program.
|
||||
"extra": [("sbin/init", "init")],
|
||||
# Further files shipped on the ESP: the init user program and the initrd
|
||||
# (VFS server + drivers), both copied from zig-out/bin.
|
||||
"extra": [("sbin/init", "init"), ("initrd.img", "initrd.img")],
|
||||
# Built as a function so we can splice in per-run paths.
|
||||
"qemu_args": lambda a, esp, vars_fd, serial: [
|
||||
"-machine", "q35", "-m", "128M",
|
||||
|
|
@ -177,6 +178,11 @@ CASES = [
|
|||
"smp": 4,
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
# The initrd: the loader ferries a bundle of user binaries; the kernel parses
|
||||
# it and spawns each as a ring-3 process (here the VFS-server stub heartbeats).
|
||||
{"name": "initrd",
|
||||
"expect": r"DANOS-TEST-RESULT: PASS",
|
||||
"fail": r"DANOS-TEST-RESULT: FAIL"},
|
||||
# The ACPI power path succeeds by QEMU *exiting* (S5 off / reset), so match the
|
||||
# pre-transition marker; the FAIL line only appears if the transition didn't take.
|
||||
{"name": "poweroff",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build-time initrd packer. Concatenates user binaries into one image the
|
||||
bootloader ferries to the kernel.
|
||||
|
||||
Usage: mkinitrd.py <out.img> [<name> <file>]...
|
||||
|
||||
Image layout (little-endian), mirroring src/user/proto/initrd.zig:
|
||||
Header : magic u32 ("DNRD"=0x444E5244), count u32
|
||||
Entry*N : name [32]u8 (NUL-padded), offset u64, len u64
|
||||
blobs : each entry's file bytes at its offset
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
|
||||
MAGIC = 0x444E5244
|
||||
HEADER = struct.Struct("<II") # magic, count
|
||||
ENTRY = struct.Struct("<32sQQ") # name[32], offset, len
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out_path = sys.argv[1]
|
||||
rest = sys.argv[2:]
|
||||
if len(rest) % 2 != 0:
|
||||
sys.stderr.write("usage: mkinitrd.py <out.img> [<name> <file>]...\n")
|
||||
return 2
|
||||
items = [(rest[i], rest[i + 1]) for i in range(0, len(rest), 2)]
|
||||
|
||||
table_end = HEADER.size + len(items) * ENTRY.size
|
||||
entries = b""
|
||||
blobs = []
|
||||
off = table_end
|
||||
for name, path in items:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
entries += ENTRY.pack(name.encode()[:31], off, len(data))
|
||||
blobs.append(data)
|
||||
off += len(data)
|
||||
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(HEADER.pack(MAGIC, len(items)))
|
||||
f.write(entries)
|
||||
for b in blobs:
|
||||
f.write(b)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue