diff --git a/package.json b/package.json index 4ce1e59..efa13ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hemlang/hpm", - "version": "1.0.8", + "version": "1.0.9", "description": "Hemlock Package Manager - A package manager for the Hemlock programming language", "author": "Hemlock Team", "license": "MIT", diff --git a/src/cache.hml b/src/cache.hml index 4b15c9e..152e9f9 100644 --- a/src/cache.hml +++ b/src/cache.hml @@ -4,6 +4,12 @@ import { exists, read_file, write_file, make_dir, list_dir, remove_file, remove_ import * as env from "@stdlib/env"; import { parse, stringify, pretty } from "@stdlib/json"; import { exec } from "@stdlib/process"; +import * as semver from "./semver.hml"; + +// Strip 'v' prefix from version string for cache paths +fn clean_version(version: string): string { + return semver.strip_v_prefix(version); +} // Get cache directory path export fn get_cache_dir(): string { @@ -71,13 +77,7 @@ fn ensure_dir(path: string) { // Get path to cached tarball export fn get_cached_tarball_path(owner: string, repo: string, version: string): string { let cache_dir = get_cache_dir(); - - // Remove 'v' prefix if present - let v = version; - if (v.length > 0 && v[0] == 'v') { - v = v.substr(1, v.length - 1); - } - + let v = clean_version(version); return cache_dir + "/" + owner + "/" + repo + "/" + v + ".tar.gz"; } @@ -95,12 +95,7 @@ export fn get_cached_path(owner: string, repo: string, version: string): string // Store tarball in cache from data export fn store_tarball(owner: string, repo: string, version: string, data) { let cache_dir = get_cache_dir(); - - // Remove 'v' prefix if present - let v = version; - if (v.length > 0 && v[0] == 'v') { - v = v.substr(1, v.length - 1); - } + let v = clean_version(version); // Ensure directory structure ensure_dir(cache_dir + "/" + owner); @@ -111,10 +106,15 @@ export fn store_tarball(owner: string, repo: string, version: string, data) { // Data might be string or buffer, handle both let f = open(path, "w"); - if (typeof(data) == "buffer") { - f.write_bytes(data); - } else { - f.write(data); + try { + if (typeof(data) == "buffer") { + f.write_bytes(data); + } else { + f.write(data); + } + } catch (e) { + f.close(); + throw e; } f.close(); @@ -124,12 +124,7 @@ export fn store_tarball(owner: string, repo: string, version: string, data) { // Store tarball in cache from a file (copies the file) export fn store_tarball_file(owner: string, repo: string, version: string, src_path: string) { let cache_dir = get_cache_dir(); - - // Remove 'v' prefix if present - let v = version; - if (v.length > 0 && v[0] == 'v') { - v = v.substr(1, v.length - 1); - } + let v = clean_version(version); // Ensure directory structure ensure_dir(cache_dir + "/" + owner); @@ -157,7 +152,13 @@ export fn read_tarball(owner: string, repo: string, version: string) { } let f = open(path, "r"); - let content = f.read(); + let content = null; + try { + content = f.read(); + } catch (e) { + f.close(); + throw e; + } f.close(); return content; diff --git a/src/github.hml b/src/github.hml index 5fb2511..cf31cb8 100644 --- a/src/github.hml +++ b/src/github.hml @@ -56,7 +56,7 @@ fn github_request(url: string) { } headers.push("Accept: application/vnd.github.v3+json"); - headers.push("User-Agent: hpm/1.0.7"); + headers.push("User-Agent: hpm/1.0.9"); let retries = 0; let backoff_ms = INITIAL_BACKOFF_MS; @@ -252,10 +252,15 @@ export fn download_tarball(owner: string, repo: string, version: string): string // Write binary data to file (use file handle for binary support) let f = open(tmpfile, "w"); - if (typeof(response.body) == "buffer") { - f.write_bytes(response.body); - } else { - f.write(response.body); + try { + if (typeof(response.body) == "buffer") { + f.write_bytes(response.body); + } else { + f.write(response.body); + } + } catch (e) { + f.close(); + throw e; } f.close(); @@ -278,7 +283,7 @@ export fn repo_exists(owner: string, repo: string): bool { } headers.push("Accept: application/vnd.github.v3+json"); - headers.push("User-Agent: hpm/1.0.7"); + headers.push("User-Agent: hpm/1.0.9"); let retries = 0; let backoff_ms = INITIAL_BACKOFF_MS; diff --git a/src/installer.hml b/src/installer.hml index dda57fd..9185f2d 100644 --- a/src/installer.hml +++ b/src/installer.hml @@ -88,23 +88,29 @@ export fn install_package(pkg_name: string, resolution, project_dir: string, ver } make_dir(extract_dir); - // Extract tarball using system tar - let tar_result = exec("tar", ["-xzf", tarball_path, "-C", extract_dir]); - if (tar_result.exit_code != 0) { - throw "Failed to extract tarball: tar exited with status " + tar_result.exit_code; - } + // Extract tarball using system tar (cleanup temp dir on any failure) + try { + let tar_result = exec("tar", ["-xzf", tarball_path, "-C", extract_dir]); + if (tar_result.exit_code != 0) { + throw "Failed to extract tarball: tar exited with status " + tar_result.exit_code; + } - // Find the extracted directory (GitHub tarballs have a root dir like "repo-version/") - let extracted_dirs = list_dir(extract_dir); - if (extracted_dirs.length == 0) { - throw "Failed to extract tarball: no files extracted"; - } - let root_dir = extract_dir + "/" + extracted_dirs[0]; + // Find the extracted directory (GitHub tarballs have a root dir like "repo-version/") + let extracted_dirs = list_dir(extract_dir); + if (extracted_dirs == null || extracted_dirs.length == 0) { + throw "Failed to extract tarball: no files extracted"; + } + let root_dir = extract_dir + "/" + extracted_dirs[0]; - // Copy contents to install_dir - let copy_result = exec("cp", ["-r", root_dir + "/.", install_dir]); - if (copy_result.exit_code != 0) { - throw "Failed to copy extracted files"; + // Copy contents to install_dir + let copy_result = exec("cp", ["-r", root_dir + "/.", install_dir]); + if (copy_result.exit_code != 0) { + throw "Failed to copy extracted files"; + } + } catch (e) { + // Clean up temp dir before re-throwing + remove_dir_recursive(extract_dir); + throw e; } // Clean up temp extraction dir @@ -119,25 +125,14 @@ export fn install_package(pkg_name: string, resolution, project_dir: string, ver integrity = "sha256-" + hash; } } catch (e) { - // Keep unknown integrity if calculation fails + if (verbose) { + print(" Warning: Could not calculate integrity hash: " + e); + } } return integrity; } -// Helper to find last occurrence of character -fn find_last(s: string, ch: rune): i32 { - let last = -1; - let i = 0; - while (i < s.length) { - if (s[i] == ch) { - last = i; - } - i = i + 1; - } - return last; -} - // Ensure directory exists recursively fn ensure_dir_recursive(path: string) { if (exists(path)) { diff --git a/src/main.hml b/src/main.hml index d4611c9..8812ee0 100644 --- a/src/main.hml +++ b/src/main.hml @@ -15,7 +15,7 @@ import * as installer from "./installer.hml"; import * as cache from "./cache.hml"; // Version info -let HPM_VERSION = "1.0.7"; +let HPM_VERSION = "1.0.9"; // Exit codes let EXIT_SUCCESS = 0; @@ -201,11 +201,7 @@ fn cmd_install() { let versions = github.get_tags(parts.owner, parts.repo); if (versions.length > 0) { let sorted = semver.sort(versions); - let latest = sorted[sorted.length - 1]; - // Remove 'v' prefix - if (latest.length > 0 && latest[0] == 'v') { - latest = latest.substr(1, latest.length - 1); - } + let latest = semver.strip_v_prefix(sorted[sorted.length - 1]); version_constraint = "^" + latest; } } @@ -606,17 +602,11 @@ fn cmd_outdated() { let versions = github.get_tags(parts.owner, parts.repo); if (versions.length > 0) { let sorted = semver.sort(versions); - let latest = sorted[sorted.length - 1]; - if (latest.length > 0 && latest[0] == 'v') { - latest = latest.substr(1, latest.length - 1); - } + let latest = semver.strip_v_prefix(sorted[sorted.length - 1]); // Find wanted (highest satisfying constraint) let parsed_constraint = semver.parse_constraint(constraint); - let wanted = semver.max_satisfying(versions, parsed_constraint); - if (wanted != null && wanted.length > 0 && wanted[0] == 'v') { - wanted = wanted.substr(1, wanted.length - 1); - } + let wanted = semver.strip_v_prefix(semver.max_satisfying(versions, parsed_constraint)); if (wanted == null) { wanted = current; } @@ -654,7 +644,6 @@ fn pad_right(s: string, width: i32): string { // hpm run fn cmd_run() { - let project_dir = get_project_dir(); let pos_args = get_positional_args(); if (pos_args.length == 0) { @@ -662,46 +651,7 @@ fn cmd_run() { env.exit(EXIT_SUCCESS); } - let script_name = pos_args[0]; - - // Read manifest - let pkg = manifest.read_manifest(project_dir); - if (pkg == null) { - print("No package.json found."); - env.exit(EXIT_INVALID_MANIFEST); - } - - if (pkg.scripts == null || pkg.scripts[script_name] == null) { - print("Script not found: " + script_name); - env.exit(EXIT_NOT_FOUND); - } - - let script_cmd = pkg.scripts[script_name]; - print("> " + script_cmd); - print(""); - - // Execute the script - // Parse command and arguments - let parts = script_cmd.split(" "); - let cmd = parts[0]; - let cmd_args = []; - let i = 1; - while (i < parts.length) { - cmd_args.push(parts[i]); - i = i + 1; - } - - // Fork and exec - let pid = fork(); - if (pid == 0) { - // Child process - exec(cmd, cmd_args); - env.exit(1); // exec failed - } else { - // Parent - wait for child - let status = waitpid(pid); - env.exit(status); - } + cmd_run_with_script(pos_args[0]); } // hpm test (shorthand for hpm run test) diff --git a/src/manifest.hml b/src/manifest.hml index 0aee151..1a03e1d 100644 --- a/src/manifest.hml +++ b/src/manifest.hml @@ -224,6 +224,34 @@ export fn parse_specifier(spec: string) { }; } +// Validate that a name component (owner or repo) contains only safe characters. +// Prevents path traversal and command injection via malicious package names. +export fn is_valid_name_component(s: string): bool { + if (s == null || s.length == 0) { + return false; + } + + // Reject path traversal attempts + if (s == "." || s == "..") { + return false; + } + + let i = 0; + while (i < s.length) { + let ch = s[i]; + let ok = (ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '-' || ch == '_' || ch == '.'; + if (!ok) { + return false; + } + i = i + 1; + } + + return true; +} + // Split package name into owner and repo export fn split_name(name: string) { let slash_pos = name.find("/"); @@ -231,8 +259,16 @@ export fn split_name(name: string) { return null; } + let owner = name.substr(0, slash_pos); + let repo = name.substr(slash_pos + 1, name.length - slash_pos - 1); + + // Validate components to prevent path traversal + if (!is_valid_name_component(owner) || !is_valid_name_component(repo)) { + return null; + } + return { - owner: name.substr(0, slash_pos), - repo: name.substr(slash_pos + 1, name.length - slash_pos - 1) + owner: owner, + repo: repo }; } diff --git a/src/resolver.hml b/src/resolver.hml index ff77483..1facdcc 100644 --- a/src/resolver.hml +++ b/src/resolver.hml @@ -172,10 +172,7 @@ fn resolve_version(pkg_name: string, pkg_constraints: array) { let tarball_url = github.get_tarball_url(parts.owner, parts.repo, best_version); // Remove 'v' prefix for clean version string - let clean_version = best_version; - if (clean_version.length > 0 && clean_version[0] == 'v') { - clean_version = clean_version.substr(1, clean_version.length - 1); - } + let clean_version = semver.strip_v_prefix(best_version); return { version: clean_version, diff --git a/src/semver.hml b/src/semver.hml index 84f3b66..b1426f8 100644 --- a/src/semver.hml +++ b/src/semver.hml @@ -392,6 +392,14 @@ export fn sort(versions: array): array { return result; } +// Strip leading 'v' or 'V' prefix from a version string +export fn strip_v_prefix(s: string): string { + if (s != null && s.length > 0 && (s[0] == 'v' || s[0] == 'V')) { + return s.substr(1, s.length - 1); + } + return s; +} + // Check if two constraints have any overlap export fn constraints_overlap(c1, c2): bool { // This is a simplified check - would need more sophisticated analysis diff --git a/test/run.hml b/test/run.hml index c02834a..94ad0d0 100644 --- a/test/run.hml +++ b/test/run.hml @@ -9,6 +9,7 @@ import * as test_semver from "./test_semver.hml"; import * as test_manifest from "./test_manifest.hml"; import * as test_lockfile from "./test_lockfile.hml"; import * as test_cache from "./test_cache.hml"; +import * as test_resolver from "./test_resolver.hml"; print("hpm Test Suite"); print("=============="); @@ -18,6 +19,7 @@ test_semver.run(); test_manifest.run(); test_lockfile.run(); test_cache.run(); +test_resolver.run(); // Print summary and exit with appropriate code let exit_code = summary(); diff --git a/test/test_manifest.hml b/test/test_manifest.hml index 71089b4..8235e75 100644 --- a/test/test_manifest.hml +++ b/test/test_manifest.hml @@ -106,6 +106,70 @@ export fn run() { assert_null(m.dependencies["owner/dep"], "dependency removed"); }); + suite("manifest.is_valid_name_component"); + + test("accepts valid name", fn() { + assert_true(manifest.is_valid_name_component("my-repo"), "valid name"); + }); + + test("accepts alphanumeric with dots", fn() { + assert_true(manifest.is_valid_name_component("my.repo.v2"), "dots allowed"); + }); + + test("accepts underscores", fn() { + assert_true(manifest.is_valid_name_component("my_repo"), "underscores allowed"); + }); + + test("rejects empty string", fn() { + assert_false(manifest.is_valid_name_component(""), "empty rejected"); + }); + + test("rejects dot-dot (path traversal)", fn() { + assert_false(manifest.is_valid_name_component(".."), ".. rejected"); + }); + + test("rejects single dot", fn() { + assert_false(manifest.is_valid_name_component("."), ". rejected"); + }); + + test("rejects slashes", fn() { + assert_false(manifest.is_valid_name_component("a/b"), "slash rejected"); + }); + + test("rejects spaces", fn() { + assert_false(manifest.is_valid_name_component("a b"), "space rejected"); + }); + + test("rejects shell metacharacters", fn() { + assert_false(manifest.is_valid_name_component("a;rm"), "semicolon rejected"); + assert_false(manifest.is_valid_name_component("a&b"), "ampersand rejected"); + assert_false(manifest.is_valid_name_component("a|b"), "pipe rejected"); + }); + + suite("manifest.split_name (validation)"); + + test("rejects path traversal in owner", fn() { + let parts = manifest.split_name("../etc/passwd"); + assert_null(parts, "path traversal rejected"); + }); + + test("rejects empty owner", fn() { + let parts = manifest.split_name("/repo"); + assert_null(parts, "empty owner rejected"); + }); + + test("rejects empty repo", fn() { + let parts = manifest.split_name("owner/"); + assert_null(parts, "empty repo rejected"); + }); + + test("accepts valid owner/repo", fn() { + let parts = manifest.split_name("hemlang/sprout"); + assert_not_null(parts, "valid name accepted"); + assert_eq(parts.owner, "hemlang", "owner"); + assert_eq(parts.repo, "sprout", "repo"); + }); + suite("manifest.get_all_dependencies"); test("gets only regular deps", fn() { diff --git a/test/test_resolver.hml b/test/test_resolver.hml new file mode 100644 index 0000000..b754e00 --- /dev/null +++ b/test/test_resolver.hml @@ -0,0 +1,127 @@ +// Tests for resolver module (unit-testable parts) + +import { suite, test, assert, assert_eq, assert_true, assert_false, assert_null, assert_not_null, assert_throws } from "./framework.hml"; +import * as resolver from "../src/resolver.hml"; +import { HashMap, Set } from "@stdlib/collections"; + +export fn run() { + suite("resolver.detect_cycles"); + + test("no cycle in empty resolved", fn() { + let resolved = HashMap(); + let cycle = resolver.detect_cycles(resolved); + assert_null(cycle, "no cycle"); + }); + + test("no cycle in linear chain", fn() { + let resolved = HashMap(); + let b_deps = {}; + b_deps["owner/c"] = "^1.0.0"; + resolved.set("owner/a", { version: "1.0.0", dependencies: { "owner/b": "^1.0.0" } }); + resolved.set("owner/b", { version: "1.0.0", dependencies: b_deps }); + resolved.set("owner/c", { version: "1.0.0", dependencies: null }); + + let cycle = resolver.detect_cycles(resolved); + assert_null(cycle, "no cycle in linear chain"); + }); + + test("detects simple cycle", fn() { + let resolved = HashMap(); + resolved.set("owner/a", { version: "1.0.0", dependencies: { "owner/b": "^1.0.0" } }); + resolved.set("owner/b", { version: "1.0.0", dependencies: { "owner/a": "^1.0.0" } }); + + let cycle = resolver.detect_cycles(resolved); + assert_not_null(cycle, "should detect cycle"); + assert_true(cycle.length >= 2, "cycle has at least 2 nodes"); + }); + + test("detects longer cycle", fn() { + let resolved = HashMap(); + resolved.set("owner/a", { version: "1.0.0", dependencies: { "owner/b": "^1.0.0" } }); + resolved.set("owner/b", { version: "1.0.0", dependencies: { "owner/c": "^1.0.0" } }); + resolved.set("owner/c", { version: "1.0.0", dependencies: { "owner/a": "^1.0.0" } }); + + let cycle = resolver.detect_cycles(resolved); + assert_not_null(cycle, "should detect cycle"); + }); + + test("no false positive with diamond deps", fn() { + let resolved = HashMap(); + resolved.set("owner/a", { version: "1.0.0", dependencies: { "owner/c": "^1.0.0" } }); + resolved.set("owner/b", { version: "1.0.0", dependencies: { "owner/c": "^1.0.0" } }); + resolved.set("owner/c", { version: "1.0.0", dependencies: null }); + + let cycle = resolver.detect_cycles(resolved); + assert_null(cycle, "diamond is not a cycle"); + }); + + suite("resolver.build_tree"); + + test("builds tree for single dep", fn() { + let root = { + name: "test/root", + version: "1.0.0", + dependencies: { "owner/a": "^1.0.0" }, + devDependencies: null + }; + + let resolved = {}; + resolved["owner/a"] = { version: "1.0.0", dependencies: null }; + + let tree = resolver.build_tree(root, resolved, false); + assert_eq(tree.name, "test/root", "root name"); + assert_eq(tree.children.length, 1, "one child"); + assert_eq(tree.children[0].name, "owner/a", "child name"); + }); + + test("builds tree with transitive deps", fn() { + let root = { + name: "test/root", + version: "1.0.0", + dependencies: { "owner/a": "^1.0.0" }, + devDependencies: null + }; + + let resolved = {}; + resolved["owner/a"] = { version: "1.0.0", dependencies: { "owner/b": "^1.0.0" } }; + resolved["owner/b"] = { version: "2.0.0", dependencies: null }; + + let tree = resolver.build_tree(root, resolved, false); + assert_eq(tree.children.length, 1, "one root child"); + assert_eq(tree.children[0].children.length, 1, "one transitive child"); + assert_eq(tree.children[0].children[0].name, "owner/b", "transitive name"); + }); + + suite("resolver.find_why"); + + test("finds direct dependency", fn() { + let root = { + name: "test/root", + version: "1.0.0", + dependencies: { "owner/a": "^1.0.0" }, + devDependencies: null + }; + + let resolved = {}; + resolved["owner/a"] = { version: "1.0.0", dependencies: null }; + + let chains = resolver.find_why("owner/a", root, resolved); + assert_true(chains.length > 0, "found at least one chain"); + }); + + test("finds indirect dependency", fn() { + let root = { + name: "test/root", + version: "1.0.0", + dependencies: { "owner/a": "^1.0.0" }, + devDependencies: null + }; + + let resolved = {}; + resolved["owner/a"] = { version: "1.0.0", dependencies: { "owner/b": "^1.0.0" } }; + resolved["owner/b"] = { version: "1.0.0", dependencies: null }; + + let chains = resolver.find_why("owner/b", root, resolved); + assert_true(chains.length > 0, "found chain for transitive dep"); + }); +} diff --git a/test/test_semver.hml b/test/test_semver.hml index b1b98cd..d70597a 100644 --- a/test/test_semver.hml +++ b/test/test_semver.hml @@ -209,6 +209,95 @@ export fn run() { assert_null(max, "none satisfy"); }); + suite("semver.satisfies (edge cases)"); + + test("caret with major 0", fn() { + let c = semver.parse_constraint("^0.2.3"); + + assert_true(semver.satisfies(semver.parse("0.2.3"), c), "0.2.3"); + assert_true(semver.satisfies(semver.parse("0.2.9"), c), "0.2.9"); + assert_false(semver.satisfies(semver.parse("0.3.0"), c), "0.3.0 too high"); + assert_false(semver.satisfies(semver.parse("1.0.0"), c), "1.0.0 too high"); + }); + + test("caret with 0.0.x", fn() { + let c = semver.parse_constraint("^0.0.3"); + + assert_true(semver.satisfies(semver.parse("0.0.3"), c), "0.0.3 exact"); + assert_false(semver.satisfies(semver.parse("0.0.4"), c), "0.0.4 too high"); + assert_false(semver.satisfies(semver.parse("0.1.0"), c), "0.1.0 too high"); + }); + + test("range constraint", fn() { + let c = semver.parse_constraint(">=1.0.0 <2.0.0"); + + assert_true(semver.satisfies(semver.parse("1.0.0"), c), "1.0.0"); + assert_true(semver.satisfies(semver.parse("1.9.9"), c), "1.9.9"); + assert_false(semver.satisfies(semver.parse("0.9.0"), c), "0.9.0 too low"); + assert_false(semver.satisfies(semver.parse("2.0.0"), c), "2.0.0 too high"); + }); + + test("lt constraint", fn() { + let c = semver.parse_constraint("<2.0.0"); + assert_true(semver.satisfies(semver.parse("1.9.9"), c), "1.9.9"); + assert_false(semver.satisfies(semver.parse("2.0.0"), c), "2.0.0"); + }); + + test("lte constraint", fn() { + let c = semver.parse_constraint("<=2.0.0"); + assert_true(semver.satisfies(semver.parse("2.0.0"), c), "2.0.0"); + assert_false(semver.satisfies(semver.parse("2.0.1"), c), "2.0.1"); + }); + + test("gt constraint", fn() { + let c = semver.parse_constraint(">1.0.0"); + assert_true(semver.satisfies(semver.parse("1.0.1"), c), "1.0.1"); + assert_false(semver.satisfies(semver.parse("1.0.0"), c), "1.0.0 not strictly greater"); + }); + + suite("semver.strip_v_prefix"); + + test("strips v prefix", fn() { + assert_eq(semver.strip_v_prefix("v1.0.0"), "1.0.0", "strips v"); + }); + + test("strips V prefix", fn() { + assert_eq(semver.strip_v_prefix("V1.0.0"), "1.0.0", "strips V"); + }); + + test("no-op without prefix", fn() { + assert_eq(semver.strip_v_prefix("1.0.0"), "1.0.0", "no change"); + }); + + test("handles null", fn() { + assert_null(semver.strip_v_prefix(null), "null returns null"); + }); + + test("handles empty string", fn() { + assert_eq(semver.strip_v_prefix(""), "", "empty stays empty"); + }); + + suite("semver.parse (edge cases)"); + + test("parses version without patch", fn() { + let v = semver.parse("1.2"); + assert_eq(v.major, 1, "major"); + assert_eq(v.minor, 2, "minor"); + assert_eq(v.patch, 0, "patch defaults to 0"); + }); + + test("parses version with only major", fn() { + let v = semver.parse("3"); + assert_eq(v.major, 3, "major"); + assert_eq(v.minor, 0, "minor defaults to 0"); + assert_eq(v.patch, 0, "patch defaults to 0"); + }); + + test("returns null for null input", fn() { + let v = semver.parse(null); + assert_null(v, "null returns null"); + }); + suite("semver.sort"); test("sorts versions ascending", fn() { @@ -219,4 +308,17 @@ export fn run() { assert_eq(sorted[2], "v1.2.0", "third"); assert_eq(sorted[3], "v2.0.0", "fourth"); }); + + test("sorts single element", fn() { + let versions = ["v1.0.0"]; + let sorted = semver.sort(versions); + assert_eq(sorted.length, 1, "length"); + assert_eq(sorted[0], "v1.0.0", "single"); + }); + + test("sorts empty array", fn() { + let versions = []; + let sorted = semver.sort(versions); + assert_eq(sorted.length, 0, "empty"); + }); }