//! Package manager backing the module-level `@fetch` directive. //! //! Downloads are cached under $RINNE_CACHE_HOME (or $HOME/.cache/rinne/pkg), //! keyed by a short sha256 of (url, version). Supported targets: //! - direct .rg file URLs -> fetched verbatim, cached as source.rg //! - .tar.gz / .tgz archive URLs -> extracted into /src/, entry *.rg discovered //! - repo shorthands w/ version -> rewritten to host archive URLs pinned by tag: //! github.com/u/r @v1.2.0 -> https://codeload.github.com/u/r/tar.gz/refs/tags/v1.2.0 //! gitlab.com/g/r @v1.2.0 -> https://gitlab.com/g/r/-/archive/v1.2.0/r-v1.2.0.tar.gz //! codeberg.org/u/r @v1.2.0 -> https://codeberg.org/u/r/archive/v1.2.0.tar.gz //! //! Network access lives behind the fetch_impl seam so tests run offline. const std = @import("std"); pub const Error = error{ OutOfMemory, UnsupportedUrl, MissingVersion, DownloadFailed, PackageTooLarge, ChecksumMismatch, OfflineMode, BadArchive, CacheUnavailable, InvalidPackage, }; /// Hard cap on a single download body (v1 buffers whole responses). pub const max_download_bytes: usize = 64 * 1024 * 1024; /// Hard cap on total extracted archive bytes. const max_extract_bytes: u64 = max_download_bytes * 8; /// Injectable transport: copies the resource at `url` into `out`. pub const FetchFn = *const fn (pm: *PackageManager, url: []const u8, out: *std.ArrayList(u8)) anyerror!void; const TargetKind = enum { direct_rg, archive }; const Target = struct { kind: TargetKind, /// Fully-resolved URL to download. fetch_url: []u8, }; fn startsWithScheme(url: []const u8, scheme: []const u8) bool { if (url.len < scheme.len + 3) return false; if (!std.mem.eql(u8, url[0..scheme.len], scheme)) return false; return url[scheme.len] == ':' and url[scheme.len + 1] == '/' and url[scheme.len + 2] == '/'; } fn isHttpScheme(url: []const u8) bool { return startsWithScheme(url, "http") or startsWithScheme(url, "https"); } /// Strips an optional scheme and returns the host portion (up to first '/'). fn hostOf(url: []const u8) []const u8 { var rest = url; if (startsWithScheme(rest, "https")) { rest = rest["https://".len..]; } else if (startsWithScheme(rest, "http")) { rest = rest["http://".len..]; } const slash = std.mem.indexOfScalar(u8, rest, '/') orelse rest.len; return rest[0..slash]; } fn isShorthandHost(host: []const u8) bool { const hosts = [_][]const u8{ "github.com", "gitlab.com", "codeberg.org" }; for (hosts) |h| { if (std.mem.eql(u8, host, h)) return true; } return false; } /// Rewrites repo shorthands into archive URLs; classifies direct URLs. /// `fetch_url` is owned by the caller (free with `allocator`). pub fn classify(allocator: std.mem.Allocator, url: []const u8, version: ?[]const u8) Error!Target { const host = hostOf(url); if (isShorthandHost(host)) { const ver = version orelse return Error.MissingVersion; var rest = url; if (startsWithScheme(rest, "https")) { rest = rest["https://".len..]; } else if (startsWithScheme(rest, "http")) { rest = rest["http://".len..]; } const after_host = if (rest.len > host.len) rest[host.len + 1 ..] else ""; var segs: [16][]const u8 = undefined; var nsegs: usize = 0; var it = std.mem.splitScalar(u8, after_host, '/'); while (it.next()) |seg| { if (seg.len == 0) continue; if (nsegs >= segs.len) break; segs[nsegs] = seg; nsegs += 1; } if (nsegs < 2) return Error.UnsupportedUrl; // GitHub/Codeberg: owner/repo (extra path ignored). GitLab allows // subgroups, so its repo is the LAST segment. var repo = if (std.mem.eql(u8, host, "gitlab.com")) segs[nsegs - 1] else segs[1]; if (std.mem.endsWith(u8, repo, ".git")) repo = repo[0 .. repo.len - 4]; const fetch_url = if (std.mem.eql(u8, host, "github.com")) try std.fmt.allocPrint(allocator, "https://codeload.github.com/{s}/{s}/tar.gz/refs/tags/{s}", .{ segs[0], repo, ver }) else if (std.mem.eql(u8, host, "gitlab.com")) blk: { var full = std.ArrayList(u8).empty; errdefer full.deinit(allocator); for (segs[0..nsegs], 0..) |seg, i| { if (i != 0) try full.append(allocator, '/'); try full.appendSlice(allocator, seg); } defer full.deinit(allocator); break :blk try std.fmt.allocPrint(allocator, "https://gitlab.com/{s}/-/archive/{s}/{s}-{s}.tar.gz", .{ full.items, ver, repo, ver }); } else try std.fmt.allocPrint(allocator, "https://codeberg.org/{s}/{s}/archive/{s}.tar.gz", .{ segs[0], repo, ver }); return .{ .kind = .archive, .fetch_url = fetch_url }; } if (!isHttpScheme(url)) return Error.UnsupportedUrl; if (std.mem.endsWith(u8, url, ".rg")) return .{ .kind = .direct_rg, .fetch_url = try allocator.dupe(u8, url) }; if (std.mem.endsWith(u8, url, ".tar.gz") or std.mem.endsWith(u8, url, ".tgz")) return .{ .kind = .archive, .fetch_url = try allocator.dupe(u8, url) }; return Error.UnsupportedUrl; } pub const PackageManager = struct { allocator: std.mem.Allocator, io: std.Io, /// Absolute path of the package cache root (owned). cache_dir: []u8, offline: bool = false, fetch_impl: FetchFn, /// Human-readable detail for the most recent error (owned; may be null). last_error: ?[]u8 = null, pub fn deinit(self: *PackageManager) void { self.allocator.free(self.cache_dir); if (self.last_error) |m| self.allocator.free(m); self.last_error = null; } /// Computes the cache root: rinne_cache_home wins, else $home/.cache/rinne/pkg. /// offline_env equal to "1" enables offline mode. pub fn init( allocator: std.mem.Allocator, io: std.Io, rinne_cache_home: ?[]const u8, home: ?[]const u8, offline_env: ?[]const u8, ) Error!PackageManager { const root: []const u8 = blk: { if (rinne_cache_home) |c| { if (c.len != 0) break :blk c; } const h = home orelse return Error.CacheUnavailable; break :blk std.fmt.allocPrint(allocator, "{s}/.cache/rinne/pkg", .{h}) catch return Error.OutOfMemory; }; const owned = allocator.dupe(u8, root) catch return Error.OutOfMemory; return .{ .allocator = allocator, .io = io, .cache_dir = owned, .offline = offline_env != null and std.mem.eql(u8, offline_env.?, "1"), .fetch_impl = defaultFetch, }; } fn setError(self: *PackageManager, comptime fmt: []const u8, args: anytype) void { if (self.last_error) |m| self.allocator.free(m); self.last_error = std.fmt.allocPrint(self.allocator, fmt, args) catch null; } fn keyFor(self: *PackageManager, url: []const u8, version: ?[]const u8) Error![32]u8 { const material = std.fmt.allocPrint(self.allocator, "{s}\x00{s}", .{ url, version orelse "" }) catch return Error.OutOfMemory; defer self.allocator.free(material); var digest: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(material, &digest, .{}); return digest; } /// Resolves url (+optional pinned version, optional sha256_hex) to /// package source text. Caller owns the returned slice. pub fn resolve( self: *PackageManager, url: []const u8, version: ?[]const u8, sha256_hex: ?[]const u8, ) Error![]u8 { const target = try classify(self.allocator, url, version); defer self.allocator.free(target.fetch_url); const digest = try self.keyFor(url, version); var key_hex: [32]u8 = undefined; const key_str = hexLower(&key_hex, digest[0..16]); const dir = std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ self.cache_dir, key_str }) catch return Error.OutOfMemory; defer self.allocator.free(dir); if (self.cacheHit(dir, url, version, sha256_hex)) |src| return src; if (self.offline) { self.setError("package '{s}' is not cached and RINNE_OFFLINE=1 is set", .{url}); return Error.OfflineMode; } var body: std.ArrayList(u8) = .empty; defer body.deinit(self.allocator); self.fetch_impl(self, target.fetch_url, &body) catch |e| { if (e == Error.PackageTooLarge) { self.setError("download from '{s}' exceeds the {d} MiB limit", .{ target.fetch_url, max_download_bytes / (1024 * 1024) }); return Error.PackageTooLarge; } self.setError("download from '{s}' failed ({s}); fetch it manually into {s}", .{ target.fetch_url, @errorName(e), self.cache_dir }); return Error.DownloadFailed; }; if (body.items.len > max_download_bytes) return Error.PackageTooLarge; if (sha256_hex) |want| { var digest_full: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(body.items, &digest_full, .{}); var got_hex: [64]u8 = undefined; const got_str = hexLower(&got_hex, &digest_full); if (!std.ascii.eqlIgnoreCase(want, got_str)) { self.setError("sha256 mismatch for '{s}': expected {s}, got {s}", .{ url, want, got_str }); return Error.ChecksumMismatch; } } std.Io.Dir.cwd().createDirPath(self.io, dir) catch { self.setError("cannot create cache directory '{s}'", .{dir}); return Error.CacheUnavailable; }; const entry_abs: []u8 = switch (target.kind) { .direct_rg => blk: { const p = std.fmt.allocPrint(self.allocator, "{s}/source.rg", .{dir}) catch return Error.OutOfMemory; std.Io.Dir.cwd().writeFile(self.io, .{ .sub_path = p, .data = body.items }) catch { self.allocator.free(p); self.setError("cannot write cache file under '{s}'", .{dir}); return Error.CacheUnavailable; }; break :blk p; }, .archive => blk: { const src_root = std.fmt.allocPrint(self.allocator, "{s}/src", .{dir}) catch return Error.OutOfMemory; defer self.allocator.free(src_root); std.Io.Dir.cwd().createDirPath(self.io, src_root) catch { self.setError("cannot create cache directory '{s}'", .{src_root}); return Error.CacheUnavailable; }; var candidates: std.ArrayList([]u8) = .empty; errdefer { for (candidates.items) |c| self.allocator.free(c); candidates.deinit(self.allocator); } try self.extractTarGz(body.items, src_root, &candidates); defer { for (candidates.items) |c| self.allocator.free(c); candidates.deinit(self.allocator); } break :blk try self.allocator.dupe(u8, try self.pickEntry(&candidates)); }, }; defer self.allocator.free(entry_abs); self.writeManifest(dir, url, version, target.fetch_url, entry_abs, sha256_hex); return std.Io.Dir.cwd().readFileAlloc(self.io, entry_abs, self.allocator, .limited(max_download_bytes)) catch { self.setError("cannot read cached package source '{s}'", .{entry_abs}); return Error.CacheUnavailable; }; } /// Returns cached source when a manifest matches (url, version) and any /// caller-supplied checksum agrees with the recorded one; null = miss. fn cacheHit(self: *PackageManager, dir: []const u8, url: []const u8, version: ?[]const u8, sha256_hex: ?[]const u8) ?[]u8 { const mf_path = std.fmt.allocPrint(self.allocator, "{s}/manifest.json", .{dir}) catch return null; defer self.allocator.free(mf_path); const text = std.Io.Dir.cwd().readFileAlloc(self.io, mf_path, self.allocator, .limited(64 * 1024)) catch return null; defer self.allocator.free(text); var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, text, .{}) catch return null; defer parsed.deinit(); const obj = switch (parsed.value) { .object => |o| o, else => return null, }; const m_url = obj.get("url") orelse return null; const m_ver = obj.get("version") orelse return null; const m_entry = obj.get("entry") orelse return null; if (m_url != .string or m_ver != .string or m_entry != .string) return null; if (!std.mem.eql(u8, m_url.string, url)) return null; if (!std.mem.eql(u8, m_ver.string, version orelse "")) return null; // A caller-supplied checksum must match what we recorded; otherwise // fall through to a fresh verified download. if (sha256_hex) |want| { const m_sha = obj.get("sha256"); const rec: []const u8 = if (m_sha != null and m_sha.? == .string) m_sha.?.string else ""; if (rec.len == 0 or !std.ascii.eqlIgnoreCase(want, rec)) return null; } return std.Io.Dir.cwd().readFileAlloc(self.io, m_entry.string, self.allocator, .limited(max_download_bytes)) catch null; } fn writeManifest( self: *PackageManager, dir: []const u8, url: []const u8, version: ?[]const u8, final_url: []const u8, entry_abs: []const u8, sha256_hex: ?[]const u8, ) void { const mf_path = std.fmt.allocPrint(self.allocator, "{s}/manifest.json", .{dir}) catch return; defer self.allocator.free(mf_path); const e_url = jsonEscape(self.allocator, url) catch return; defer self.allocator.free(e_url); const e_ver = jsonEscape(self.allocator, version orelse "") catch return; defer self.allocator.free(e_ver); const e_final = jsonEscape(self.allocator, final_url) catch return; defer self.allocator.free(e_final); const e_entry = jsonEscape(self.allocator, entry_abs) catch return; defer self.allocator.free(e_entry); const e_sha = jsonEscape(self.allocator, sha256_hex orelse "") catch return; defer self.allocator.free(e_sha); const body = std.fmt.allocPrint(self.allocator, "{{\"url\":\"{s}\",\"version\":\"{s}\",\"final_url\":\"{s}\",\"entry\":\"{s}\",\"sha256\":\"{s}\"}}\n", .{ e_url, e_ver, e_final, e_entry, e_sha }) catch return; defer self.allocator.free(body); // Best-effort: a missing manifest only costs a re-download. std.Io.Dir.cwd().writeFile(self.io, .{ .sub_path = mf_path, .data = body }) catch {}; } /// Chooses the entry .rg among extracted candidates. The returned slice /// points into the candidates list (dupe before freeing that list). pub fn pickEntry(self: *PackageManager, candidates: *std.ArrayList([]u8)) Error![]u8 { if (candidates.items.len == 0) { self.setError("package archive contains no .rg files", .{}); return Error.InvalidPackage; } if (candidates.items.len == 1) return candidates.items[0]; for (candidates.items) |c| { const base = std.fs.path.basename(c); if (std.mem.eql(u8, base, "main.rg")) return c; } std.mem.sort([]u8, candidates.items, {}, struct { fn lt(_: void, a: []u8, b: []u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); var msg: std.ArrayList(u8) = .empty; const upper = @min(candidates.items.len, 3); for (candidates.items[0..upper]) |c| { msg.appendSlice(self.allocator, std.fs.path.basename(c)) catch break; msg.appendSlice(self.allocator, " ") catch break; } self.setError("ambiguous package: multiple .rg roots ({s}); import a direct .rg URL or name one 'main.rg'", .{msg.items}); msg.deinit(self.allocator); return Error.InvalidPackage; } /// Gunzips gz_bytes and extracts safe members under dest_abs. /// Appends absolute paths of extracted *.rg files to rg_out. pub fn extractTarGz( self: *PackageManager, gz_bytes: []const u8, dest_abs: []const u8, rg_out: *std.ArrayList([]u8), ) Error!void { var in_fixed = std.Io.Reader.fixed(gz_bytes); const window = self.allocator.alloc(u8, 65536) catch return Error.OutOfMemory; defer self.allocator.free(window); var dec = std.compress.flate.Decompress.init(&in_fixed, .gzip, window); const raw = dec.reader.allocRemaining(self.allocator, .limited(max_extract_bytes)) catch |e| { if (e == error.StreamTooLong) { self.setError("package expands beyond the extraction limit", .{}); return Error.PackageTooLarge; } self.setError("package is not a valid gzip stream", .{}); return Error.BadArchive; }; defer self.allocator.free(raw); try self.extractTar(raw, dest_abs, rg_out); } /// Extracts a plain tar stream (unit-testable without a compressor). pub fn extractTar( self: *PackageManager, tar_bytes: []const u8, dest_abs: []const u8, rg_out: *std.ArrayList([]u8), ) Error!void { var total: u64 = 0; var name_buf: [std.fs.max_path_bytes]u8 = undefined; var link_buf: [std.fs.max_path_bytes]u8 = undefined; var fixed = std.Io.Reader.fixed(tar_bytes); var it = std.tar.Iterator.init(&fixed, .{ .file_name_buffer = &name_buf, .link_name_buffer = &link_buf, }); while (it.next() catch { self.setError("package archive is corrupt", .{}); return Error.BadArchive; }) |tf| { switch (tf.kind) { .directory => { const d = safeJoin(self.allocator, dest_abs, tf.name) catch return Error.BadArchive; defer self.allocator.free(d); std.Io.Dir.cwd().createDirPath(self.io, d) catch return Error.CacheUnavailable; }, .file => { if (tf.size > max_extract_bytes - total) return Error.PackageTooLarge; total += tf.size; const data = self.allocator.alloc(u8, @intCast(tf.size)) catch return Error.OutOfMemory; defer self.allocator.free(data); // streamRemaining (NOT raw reads) keeps the iterator's // unread-byte bookkeeping consistent across entries. var data_writer: std.Io.Writer = .fixed(data); it.streamRemaining(tf, &data_writer) catch return Error.BadArchive; const out_path = safeJoin(self.allocator, dest_abs, tf.name) catch return Error.BadArchive; defer self.allocator.free(out_path); if (std.fs.path.dirname(out_path)) |parent| { std.Io.Dir.cwd().createDirPath(self.io, parent) catch return Error.CacheUnavailable; } std.Io.Dir.cwd().writeFile(self.io, .{ .sub_path = out_path, .data = data }) catch return Error.CacheUnavailable; if (std.mem.endsWith(u8, tf.name, ".rg")) { const dup = self.allocator.dupe(u8, out_path) catch return Error.OutOfMemory; rg_out.append(self.allocator, dup) catch { self.allocator.free(dup); return Error.OutOfMemory; }; } }, else => continue, // symlinks/devices skipped outright } } } }; /// Joins base and rel rejecting path traversal: absolute members, "..", /// "." components, and NUL bytes are all rejected. fn safeJoin(allocator: std.mem.Allocator, base: []const u8, rel: []const u8) ![]u8 { if (rel.len == 0 or rel[0] == '/') return error.BadPath; var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); try out.appendSlice(allocator, base); var it = std.mem.splitScalar(u8, rel, '/'); while (it.next()) |comp| { if (comp.len == 0) continue; if (std.mem.eql(u8, comp, "..") or std.mem.eql(u8, comp, ".")) return error.BadPath; if (std.mem.indexOfScalar(u8, comp, 0) != null) return error.BadPath; try out.append(allocator, '/'); try out.appendSlice(allocator, comp); } return out.toOwnedSlice(allocator); } fn jsonEscape(allocator: std.mem.Allocator, s: []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); for (s) |c| { switch (c) { '"' => try out.appendSlice(allocator, "\\\""), '\\' => try out.appendSlice(allocator, "\\\\"), '\n' => try out.appendSlice(allocator, "\\n"), '\r' => try out.appendSlice(allocator, "\\r"), '\t' => try out.appendSlice(allocator, "\\t"), else => { if (c < 0x20) { var buf: [6]u8 = undefined; _ = std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{c}) catch unreachable; try out.appendSlice(allocator, &buf); } else { try out.append(allocator, c); } }, } } return out.toOwnedSlice(allocator); } fn hexLower(out: []u8, bytes: []const u8) []const u8 { const charset = std.fmt.hex_charset; for (bytes, 0..) |b, i| { out[i * 2] = charset[b >> 4]; out[i * 2 + 1] = charset[b & 15]; } return out[0 .. bytes.len * 2]; } /// Production transport: one-shot HTTP fetch into an allocating writer, /// then copied into the caller's buffer (whole-body buffering is a /// documented v1 limitation). pub fn defaultFetch(pm: *PackageManager, url: []const u8, out: *std.ArrayList(u8)) anyerror!void { var client: std.http.Client = .{ .allocator = pm.allocator, .io = pm.io }; defer client.deinit(); var aw = std.Io.Writer.Allocating.init(pm.allocator); defer aw.deinit(); const result = client.fetch(.{ .location = .{ .url = url }, .response_writer = &aw.writer }) catch return Error.DownloadFailed; if (result.status != .ok) return Error.DownloadFailed; if (aw.written().len > max_download_bytes) return Error.PackageTooLarge; try out.appendSlice(pm.allocator, aw.written()); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const testing = std.testing; test "classify: shorthand hosts require versions and rewrite to archives" { const a = testing.allocator; try testing.expectError(Error.MissingVersion, classify(a, "github.com/user/lib", null)); try testing.expectError(Error.MissingVersion, classify(a, "https://codeberg.org/user/lib", null)); { const t = try classify(a, "github.com/user/lib", "v1.2.0"); defer a.free(t.fetch_url); try testing.expect(t.kind == .archive); try testing.expectEqualStrings("https://codeload.github.com/user/lib/tar.gz/refs/tags/v1.2.0", t.fetch_url); } { const t = try classify(a, "https://github.com/user/lib.git/tree/main", "v2"); defer a.free(t.fetch_url); try testing.expectEqualStrings("https://codeload.github.com/user/lib/tar.gz/refs/tags/v2", t.fetch_url); } { const t = try classify(a, "https://gitlab.com/group/sub/repo", "v1"); defer a.free(t.fetch_url); try testing.expectEqualStrings("https://gitlab.com/group/sub/repo/-/archive/v1/repo-v1.tar.gz", t.fetch_url); } { const t = try classify(a, "codeberg.org/user/lib", "v0.1.0"); defer a.free(t.fetch_url); try testing.expectEqualStrings("https://codeberg.org/user/lib/archive/v0.1.0.tar.gz", t.fetch_url); } { const t = try classify(a, "https://example.com/libs/math.rg", null); defer a.free(t.fetch_url); try testing.expect(t.kind == .direct_rg); try testing.expectEqualStrings("https://example.com/libs/math.rg", t.fetch_url); } { const t = try classify(a, "http://example.com/bundle.tgz", null); defer a.free(t.fetch_url); try testing.expect(t.kind == .archive); } try testing.expectError(Error.UnsupportedUrl, classify(a, "ftp://example.com/x.rg", null)); try testing.expectError(Error.UnsupportedUrl, classify(a, "https://example.com/notarg.txt", null)); try testing.expectError(Error.UnsupportedUrl, classify(a, "local/path/file.rg", null)); try testing.expectError(Error.UnsupportedUrl, classify(a, "github.com", "v1")); } /// Minimal ustar writer for fixtures: 512-byte header + data + padding. const TestTar = struct { list: std.ArrayList(u8) = .empty, fn deinit(self: *TestTar, a: std.mem.Allocator) void { self.list.deinit(a); } fn addFile(self: *TestTar, a: std.mem.Allocator, name: []const u8, data: []const u8) !void { var hdr = [_]u8{0} ** 512; @memcpy(hdr[0..@min(name.len, 100)], name); _ = std.fmt.bufPrint(hdr[100..108], "{o:0>7}\x00", .{0o644}) catch unreachable; _ = std.fmt.bufPrint(hdr[124..136], "{o:0>11}\x00", .{data.len}) catch unreachable; _ = std.fmt.bufPrint(hdr[136..148], "{o:0>11}\x00", .{0}) catch unreachable; hdr[156] = '0'; // regular file @memcpy(hdr[257..263], "ustar\x00"); @memcpy(hdr[263..265], "00"); var sum: u64 = 0; @memset(hdr[148..156], ' '); for (hdr) |c| sum += c; _ = std.fmt.bufPrint(hdr[148..156], "{o:0>6}\x00 ", .{sum}) catch unreachable; try self.list.appendSlice(a, &hdr); try self.list.appendSlice(a, data); const pad = (512 - @mod(data.len, 512)) % 512; try self.list.appendNTimes(a, 0, pad); } fn addDir(self: *TestTar, a: std.mem.Allocator, name: []const u8) !void { var hdr = [_]u8{0} ** 512; @memcpy(hdr[0..@min(name.len, 100)], name); _ = std.fmt.bufPrint(hdr[100..108], "{o:0>7}\x00", .{0o755}) catch unreachable; _ = std.fmt.bufPrint(hdr[124..136], "{o:0>11}\x00", .{0}) catch unreachable; _ = std.fmt.bufPrint(hdr[136..148], "{o:0>11}\x00", .{0}) catch unreachable; hdr[156] = '5'; // directory @memcpy(hdr[257..263], "ustar\x00"); @memcpy(hdr[263..265], "00"); var sum: u64 = 0; @memset(hdr[148..156], ' '); for (hdr) |c| sum += c; _ = std.fmt.bufPrint(hdr[148..156], "{o:0>6}\x00 ", .{sum}) catch unreachable; try self.list.appendSlice(a, &hdr); } fn finish(self: *TestTar, a: std.mem.Allocator) !void { try self.list.appendNTimes(a, 0, 1024); // end-of-archive blocks } fn bytes(self: *TestTar) []const u8 { return self.list.items; } }; const sample_pkg = "fn pkg_add(a: int, b: int) int {\n return a + b;\n}\n"; // Fake transport serving a direct .rg payload. var fake_calls: usize = 0; fn fakeDirectRg(pm: *PackageManager, url: []const u8, out: *std.ArrayList(u8)) anyerror!void { fake_calls += 1; try testing.expectEqualStrings("https://example.com/lib.rg", url); try out.appendSlice(pm.allocator, sample_pkg); } fn testCacheRoot(buf: []u8) ![]u8 { const ts = std.Io.Timestamp.now(testing.io, .awake); return std.fmt.bufPrint(buf, "/tmp/opencode/rg/pkgtest-{d}", .{ts.nanoseconds}); } test "resolve: direct .rg downloads, verifies sha256, and caches" { var sha: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(sample_pkg, &sha, .{}); var hex_buf: [64]u8 = undefined; const sha_hex = hexLower(&hex_buf, &sha); var root_buf: [128]u8 = undefined; const root = try testCacheRoot(&root_buf); var pm = PackageManager{ .allocator = testing.allocator, .io = testing.io, .cache_dir = try testing.allocator.dupe(u8, root), .fetch_impl = fakeDirectRg, }; defer pm.deinit(); const src1 = try pm.resolve("https://example.com/lib.rg", null, sha_hex); defer testing.allocator.free(src1); try testing.expectEqualStrings(sample_pkg, src1); try testing.expectEqual(@as(usize, 1), fake_calls); // Second resolve is a pure cache hit. const src2 = try pm.resolve("https://example.com/lib.rg", null, sha_hex); defer testing.allocator.free(src2); try testing.expectEqualStrings(sample_pkg, src2); try testing.expectEqual(@as(usize, 1), fake_calls); // Wrong sha fails; the cache is distrusted (recorded sha differs), so // this path re-downloads before failing verification. try testing.expectError(Error.ChecksumMismatch, pm.resolve("https://example.com/lib.rg", null, "deadbeef")); try testing.expect(std.mem.indexOf(u8, pm.last_error.?, "mismatch") != null); try testing.expectEqual(@as(usize, 2), fake_calls); // A distinct version string forms a distinct cache key (re-fetch). const src3 = try pm.resolve("https://example.com/lib.rg", "v9", null); defer testing.allocator.free(src3); try testing.expectEqual(@as(usize, 3), fake_calls); } test "resolve: archive extraction discovers main.rg and rejects traversal" { var root_buf: [128]u8 = undefined; const root = try testCacheRoot(&root_buf); var pm = PackageManager{ .allocator = testing.allocator, .io = testing.io, .cache_dir = try testing.allocator.dupe(u8, root), .fetch_impl = fakeDirectRg, // unused here }; defer pm.deinit(); var tt: TestTar = .{}; defer tt.deinit(testing.allocator); try tt.addDir(testing.allocator, "lib-1/"); try tt.addFile(testing.allocator, "lib-1/main.rg", sample_pkg); try tt.addFile(testing.allocator, "lib-1/util.rg", "// util\n"); try tt.finish(testing.allocator); const src_root = try std.fmt.allocPrint(testing.allocator, "{s}/manual/src", .{root}); defer testing.allocator.free(src_root); var candidates: std.ArrayList([]u8) = .empty; defer { for (candidates.items) |c| testing.allocator.free(c); candidates.deinit(testing.allocator); } try pm.extractTar(tt.bytes(), src_root, &candidates); try testing.expectEqual(@as(usize, 2), candidates.items.len); // main.rg preferred over util.rg. const picked = try pm.pickEntry(&candidates); try testing.expect(std.mem.indexOf(u8, picked, "main.rg") != null); // Traversal members are rejected outright. var evil: TestTar = .{}; defer evil.deinit(testing.allocator); try evil.addFile(testing.allocator, "../evil.rg", "boom\n"); try evil.finish(testing.allocator); var evil_cands: std.ArrayList([]u8) = .empty; defer { for (evil_cands.items) |c| testing.allocator.free(c); evil_cands.deinit(testing.allocator); } const evil_root = try std.fmt.allocPrint(testing.allocator, "{s}/evil", .{root}); defer testing.allocator.free(evil_root); try testing.expectError(Error.BadArchive, pm.extractTar(evil.bytes(), evil_root, &evil_cands)); } test "resolve: offline mode refuses uncached packages" { var root_buf: [128]u8 = undefined; const root = try testCacheRoot(&root_buf); var pm = PackageManager{ .allocator = testing.allocator, .io = testing.io, .cache_dir = try testing.allocator.dupe(u8, root), .fetch_impl = fakeDirectRg, }; defer pm.deinit(); pm.offline = true; try testing.expectError(Error.OfflineMode, pm.resolve("https://example.com/lib.rg", null, null)); }