From afe8c0571a190f4886fc40f61714ec45c248ad76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 14:26:29 +0000 Subject: [PATCH 1/2] test: add red tests for wflpkg removal Pins the post-removal contract before the code changes land, so the removal has auditable Red->Green evidence (root testing.md ss3/ss6). Red run against the current tree (6 failing for the intended reasons, 2 regression guards green): package_protocol_no_longer_resolves ......... FAILED (import succeeds today) package_import_failure_mentions_no_package_manager FAILED (no failure to inspect) bare_package_prefix_is_not_special_cased .... FAILED ("requires a package name") package_subcommands_are_removed ............. FAILED (wfl logout exits 0) run_and_test_positional_aliases_are_removed . FAILED (wfl run main.wfl runs it) help_has_no_package_management_section ...... FAILED (PACKAGE MANAGEMENT present) relative_module_paths_still_work ............ ok relative_load_module_still_works ............ ok The package tests invoke the script by absolute path: the resolver's find_project_root walked up from the source file's parent, so a bare relative argument gave it an empty parent and it bailed out before ever reaching packages/. The absolute path exercises the real resolve path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011TH11eg1F6V8W4t1Nmo3xx --- tests/package_protocol_removed_test.rs | 298 +++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 tests/package_protocol_removed_test.rs diff --git a/tests/package_protocol_removed_test.rs b/tests/package_protocol_removed_test.rs new file mode 100644 index 00000000..3a096c91 --- /dev/null +++ b/tests/package_protocol_removed_test.rs @@ -0,0 +1,298 @@ +//! CLI and runtime contract after the `wflpkg` package manager was removed. +//! +//! WFL shipped an in-tree package manager (the `wflpkg` crate) that reached the +//! language in two places: a set of positional `wfl` subcommands +//! (`create`/`add`/`share`/`login`/…) and a `package:` prefix understood by +//! `load module from` / `include from`, which resolved through +//! `packages//` next to a `project.wfl` manifest. +//! +//! That system is being redesigned from scratch, so it was removed wholesale +//! before the first RC. These tests pin the post-removal behavior: +//! +//! * `package:` is no longer a protocol — it is just an ordinary (unresolvable) +//! relative path, and none of the package manager's guidance strings survive. +//! * the positional subcommands are gone, including the `wfl run ` and +//! `wfl test ` aliases that lived inside the same dispatch block. +//! * the file-based module system that `package:` was bolted onto is untouched. + +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +fn wfl_exe() -> &'static str { + env!("CARGO_BIN_EXE_wfl") +} + +/// Run `wfl ` with `dir` as the working directory. +/// Returns (stdout, stderr, exit code). +fn run_in(dir: &Path, args: &[&str]) -> (String, String, Option) { + let output = Command::new(wfl_exe()) + .args(args) + .current_dir(dir) + .output() + .expect("failed to execute WFL"); + ( + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + output.status.code(), + ) +} + +/// Run a script by absolute path, with `dir` as the working directory. +/// +/// The absolute path matters for the package tests: the removed +/// `find_project_root` walked up from the *source file's* parent directory, so +/// a bare `main.wfl` argument gave it an empty parent and it bailed out before +/// ever consulting `packages/`. Passing the absolute path exercises the +/// resolver for real, which is what makes these tests fail against the old +/// code for the intended reason rather than by accident. +fn run_script(dir: &Path, name: &str) -> (String, String, Option) { + let script = dir.join(name); + let script = script.to_str().expect("utf-8 temp path"); + run_in(dir, &[script]) +} + +/// Build the exact project layout the removed resolver resolved successfully: +/// a `project.wfl` manifest at the root (so the old `find_project_root` walk +/// succeeded) and an installed package at `packages/demo/main.wfl` (the old +/// root-`main.wfl` entry-point fallback). +fn project_with_installed_package(dir: &Path) { + fs::write( + dir.join("project.wfl"), + "name is demo-app\nversion is 26.1.1\ndescription is Test\nentry is main.wfl\n", + ) + .expect("write project.wfl"); + + let pkg_dir = dir.join("packages").join("demo"); + fs::create_dir_all(&pkg_dir).expect("create packages/demo"); + fs::write( + pkg_dir.join("main.wfl"), + "display \"package module loaded\"\n", + ) + .expect("write packages/demo/main.wfl"); +} + +/// A `package:` import must fail as an ordinary unresolvable path, even when a +/// project manifest and a fully installed package are sitting right there. +#[test] +fn package_protocol_no_longer_resolves() { + let dir = TempDir::new().expect("tempdir"); + project_with_installed_package(dir.path()); + fs::write( + dir.path().join("main.wfl"), + "load module from \"package:demo\"\ndisplay \"main finished\"\n", + ) + .expect("write main.wfl"); + + let (stdout, stderr, code) = run_script(dir.path(), "main.wfl"); + let combined = format!("{stdout}{stderr}"); + + assert_ne!( + code, + Some(0), + "a package: import must not succeed; got:\n{combined}" + ); + assert!( + combined.contains("Cannot resolve module path"), + "a package: import should fail as a plain unresolvable path; got:\n{combined}" + ); + assert!( + !stdout.contains("package module loaded"), + "the installed package must not have been executed; got:\n{combined}" + ); +} + +/// Negative assertion: none of the package manager's guidance strings may +/// survive in the failure path. Their presence would mean a wflpkg error branch +/// is still wired into the interpreter. +#[test] +fn package_import_failure_mentions_no_package_manager() { + let dir = TempDir::new().expect("tempdir"); + project_with_installed_package(dir.path()); + fs::write( + dir.path().join("main.wfl"), + "load module from \"package:demo\"\n", + ) + .expect("write main.wfl"); + + let (stdout, stderr, code) = run_script(dir.path(), "main.wfl"); + let combined = format!("{stdout}{stderr}"); + + // Guard against a vacuous pass: if the import ever succeeds again there is + // no failure text to inspect, and the loop below would assert nothing. + assert_ne!( + code, + Some(0), + "expected the package: import to fail so its message can be checked; got:\n{combined}" + ); + + for forbidden in [ + "project.wfl", + "wfl create project", + "wfl add", + "is not installed", + "packages directory", + ] { + assert!( + !combined.contains(forbidden), + "removed package-manager guidance {forbidden:?} still appears in:\n{combined}" + ); + } +} + +/// An empty `package:` prefix gets no special diagnostic either. +#[test] +fn bare_package_prefix_is_not_special_cased() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("main.wfl"), + "load module from \"package:\"\n", + ) + .expect("write main.wfl"); + + let (stdout, stderr, code) = run_script(dir.path(), "main.wfl"); + let combined = format!("{stdout}{stderr}"); + + assert_ne!(code, Some(0), "expected failure; got:\n{combined}"); + assert!( + !combined.contains("requires a package name"), + "the package: protocol diagnostic should be gone; got:\n{combined}" + ); +} + +/// The positional package-manager subcommands are gone. Each is now treated as +/// an ordinary (missing) input file path. +#[test] +fn package_subcommands_are_removed() { + for args in [ + vec!["create", "project"], + vec!["add", "some-lib"], + vec!["remove", "some-lib"], + vec!["update"], + vec!["build"], + vec!["share"], + vec!["search", "http"], + vec!["info", "some-lib"], + vec!["login"], + vec!["logout"], + vec!["check", "security"], + ] { + let dir = TempDir::new().expect("tempdir"); + let (stdout, stderr, code) = run_in(dir.path(), &args); + let combined = format!("{stdout}{stderr}"); + + assert_ne!( + code, + Some(0), + "`wfl {}` should no longer be a subcommand; got:\n{combined}", + args.join(" ") + ); + assert!( + !dir.path().join("project.wfl").exists(), + "`wfl {}` must not create a project manifest", + args.join(" ") + ); + } +} + +/// `wfl --help` no longer advertises package management. +#[test] +fn help_has_no_package_management_section() { + let dir = TempDir::new().expect("tempdir"); + let (stdout, stderr, code) = run_in(dir.path(), &["--help"]); + let combined = format!("{stdout}{stderr}"); + + assert_eq!(code, Some(0), "--help should exit 0; got:\n{combined}"); + assert!( + stdout.contains("USAGE:"), + "--help should still print the usage banner; got:\n{combined}" + ); + for forbidden in ["PACKAGE MANAGEMENT", "Publish to the registry", "wflhub"] { + assert!( + !stdout.contains(forbidden), + "--help still mentions {forbidden:?}; got:\n{combined}" + ); + } +} + +/// The `wfl run ` and `wfl test ` positional aliases lived inside +/// the package-subcommand dispatch block and went with it. Only `wfl ` +/// and `wfl --test ` remain. +#[test] +fn run_and_test_positional_aliases_are_removed() { + let dir = TempDir::new().expect("tempdir"); + fs::write(dir.path().join("main.wfl"), "display \"hello from wfl\"\n").expect("write main.wfl"); + + for alias in ["run", "test"] { + let (stdout, stderr, code) = run_in(dir.path(), &[alias, "main.wfl"]); + let combined = format!("{stdout}{stderr}"); + assert_ne!( + code, + Some(0), + "`wfl {alias} main.wfl` should no longer be accepted; got:\n{combined}" + ); + assert!( + !stdout.contains("hello from wfl"), + "`wfl {alias} main.wfl` must not execute the program; got:\n{combined}" + ); + } +} + +/// Regression guard: the documented spellings still work, and the file-based +/// module system that `package:` was bolted onto is untouched. +#[test] +fn relative_module_paths_still_work() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("helper.wfl"), + "define action called shout with parameters word:\n \ + display \"helper says \" with word\nend action\n", + ) + .expect("write helper.wfl"); + fs::write( + dir.path().join("main.wfl"), + "include from \"helper.wfl\"\ncall shout with \"hi\"\n", + ) + .expect("write main.wfl"); + + let (stdout, stderr, code) = run_script(dir.path(), "main.wfl"); + let combined = format!("{stdout}{stderr}"); + assert_eq!( + code, + Some(0), + "relative include should run; got:\n{combined}" + ); + assert!( + stdout.contains("helper says hi"), + "relative include should execute the helper; got:\n{combined}" + ); +} + +/// Regression guard: `load module from` with a relative path still executes. +#[test] +fn relative_load_module_still_works() { + let dir = TempDir::new().expect("tempdir"); + fs::write( + dir.path().join("side_effect.wfl"), + "display \"module ran\"\n", + ) + .expect("write side_effect.wfl"); + fs::write( + dir.path().join("main.wfl"), + "load module from \"side_effect.wfl\"\n", + ) + .expect("write main.wfl"); + + let (stdout, stderr, code) = run_script(dir.path(), "main.wfl"); + let combined = format!("{stdout}{stderr}"); + assert_eq!( + code, + Some(0), + "relative module load should run; got:\n{combined}" + ); + assert!( + stdout.contains("module ran"), + "relative module load should execute the module; got:\n{combined}" + ); +} From 2f1786dd4c92e8da6205f68ba92398a9f20994bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 15:03:50 +0000 Subject: [PATCH 2/2] refactor!: remove the wflpkg package manager system WFL is heading into its first RC, and the package manager is being rethought from scratch. Package managers are the hardest part of a project to revise after release -- manifest, lockfile and archive formats, and above all the registry trust root, are things other people build against as soon as they exist. Withdrawing it before the RC costs nothing because nothing depends on it yet; shipping and then redesigning would break every early package. Removed: * crates/wflpkg in full -- manifest/lockfile parsers, the resolver, the .wflpkg archive format, the wflhash:v2: integrity transcript, the download cache, the wflhub.org registry client and credential store, and the standalone wflpkg binary. Drops five dependencies nothing else used (rpassword, flate2, tar, ignore, unix libc) and reqwest's multipart feature. * the positional wfl subcommands (create/add/remove/update/build/run/ share/search/info/login/logout/check), DEFAULT_REGISTRY, parse_create_project_args, and the PACKAGE MANAGEMENT help section. * the package: import protocol in the interpreter, plus resolve_package_path and find_project_root. * the eight [Unreleased] Security CHANGELOG bullets covering package publishing and registry credentials -- they describe code that will never ship a release. Kept: the file-based module system (load module from / include from / export) is untouched and covered by two regression tests. The design documents move to Docs/Archive/wflpkg/ with a README stating up front that nothing in the folder describes shipping behavior. BREAKING CHANGE: `wfl run .wfl` and `wfl test .wfl` were handled inside the removed subcommand block and go with it. Use the documented spellings `wfl ` and `wfl --test `; neither alias appeared in --help, CLAUDE.md, or Docs/. Confirmed with the Maintainer before implementation. Risk class R3 (backward compatibility). Red evidence in afe8c05, a test-only ancestor of this commit: 6 of 8 tests in tests/package_protocol_removed_test.rs failed there for the intended reasons, 2 were green regression guards; all 8 are green here. Verification: cargo fmt --check clean; clippy -D warnings clean; cargo test --workspace 130 binaries / 0 failures; TestPrograms 110 passed / 0 failed / 24 skipped against the release binary; validate_docs_examples.py 18/18. See the Dev diary entry for the disk-allowance workaround used to fit the debug test build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011TH11eg1F6V8W4t1Nmo3xx --- .github/workflows/ci.yml | 8 +- AGENTS.md | 1 - CHANGELOG.md | 64 +- CLAUDE.md | 1 - Cargo.lock | 148 ---- Cargo.toml | 4 +- Dev diary/2026-07-26-remove-wflpkg-system.md | 169 ++++ Docs/04-advanced-features/modules.md | 6 - Docs/Archive/wflpkg/README.md | 49 ++ .../wflpkg/wflhub_language_gaps_prd.md | 0 ...lpkg-adr-001-binary-and-crate-structure.md | 0 .../wflpkg}/wflpkg-brainstorm-results.md | 0 .../wflpkg}/wflpkg-manifest-grammar-1.0.md | 0 .../wflpkg}/wflpkg-open-decisions-resolved.md | 0 .../Archive/wflpkg/wflpkg_prd.md | 0 .../Archive/wflpkg}/wflpkgdesign.md | 0 GOVERNANCE.md | 13 +- crates/wflpkg/Cargo.toml | 28 - crates/wflpkg/src/archive.rs | 491 ------------ crates/wflpkg/src/cache/mod.rs | 409 ---------- crates/wflpkg/src/checksum.rs | 440 ---------- crates/wflpkg/src/commands/add.rs | 132 --- crates/wflpkg/src/commands/build.rs | 91 --- crates/wflpkg/src/commands/check.rs | 161 ---- crates/wflpkg/src/commands/create.rs | 211 ----- crates/wflpkg/src/commands/info.rs | 41 - crates/wflpkg/src/commands/login.rs | 211 ----- crates/wflpkg/src/commands/mod.rs | 11 - crates/wflpkg/src/commands/remove.rs | 153 ---- crates/wflpkg/src/commands/run.rs | 58 -- crates/wflpkg/src/commands/search.rs | 27 - crates/wflpkg/src/commands/share.rs | 289 ------- crates/wflpkg/src/commands/update.rs | 57 -- crates/wflpkg/src/error.rs | 267 ------- crates/wflpkg/src/lib.rs | 43 - crates/wflpkg/src/lockfile/mod.rs | 46 -- crates/wflpkg/src/lockfile/parser.rs | 134 ---- crates/wflpkg/src/lockfile/writer.rs | 77 -- crates/wflpkg/src/main.rs | 270 ------- crates/wflpkg/src/manifest/mod.rs | 74 -- crates/wflpkg/src/manifest/parser.rs | 272 ------- crates/wflpkg/src/manifest/version.rs | 273 ------- crates/wflpkg/src/manifest/writer.rs | 138 ---- crates/wflpkg/src/package_files.rs | 301 ------- crates/wflpkg/src/permissions.rs | 75 -- crates/wflpkg/src/registry/advisory.rs | 87 -- crates/wflpkg/src/registry/api.rs | 474 ----------- crates/wflpkg/src/registry/auth.rs | 479 ----------- crates/wflpkg/src/registry/mod.rs | 3 - crates/wflpkg/src/resolver/algorithm.rs | 205 ----- crates/wflpkg/src/resolver/mod.rs | 12 - crates/wflpkg/src/resolver/package_path.rs | 248 ------ crates/wflpkg/src/workspace/mod.rs | 8 - crates/wflpkg/src/workspace/parser.rs | 94 --- crates/wflpkg/tests/error_handling.rs | 282 ------- crates/wflpkg/tests/security_tests.rs | 750 ------------------ .../tests/version_and_lockfile_tests.rs | 579 -------------- crates/wflpkg/tests/workflow_integration.rs | 371 --------- fuzz/Cargo.lock | 220 ----- scripts/test_docs_code_blocks.py | 3 +- src/interpreter/mod.rs | 113 --- src/main.rs | 213 +---- 62 files changed, 266 insertions(+), 9118 deletions(-) create mode 100644 Dev diary/2026-07-26-remove-wflpkg-system.md create mode 100644 Docs/Archive/wflpkg/README.md rename wflhub_language_gaps_prd.md => Docs/Archive/wflpkg/wflhub_language_gaps_prd.md (100%) rename {wflpkg => Docs/Archive/wflpkg}/wflpkg-adr-001-binary-and-crate-structure.md (100%) rename {wflpkg => Docs/Archive/wflpkg}/wflpkg-brainstorm-results.md (100%) rename {wflpkg => Docs/Archive/wflpkg}/wflpkg-manifest-grammar-1.0.md (100%) rename {wflpkg => Docs/Archive/wflpkg}/wflpkg-open-decisions-resolved.md (100%) rename wflpkg_prd.md => Docs/Archive/wflpkg/wflpkg_prd.md (100%) rename {wflpkg => Docs/Archive/wflpkg}/wflpkgdesign.md (100%) delete mode 100644 crates/wflpkg/Cargo.toml delete mode 100644 crates/wflpkg/src/archive.rs delete mode 100644 crates/wflpkg/src/cache/mod.rs delete mode 100644 crates/wflpkg/src/checksum.rs delete mode 100644 crates/wflpkg/src/commands/add.rs delete mode 100644 crates/wflpkg/src/commands/build.rs delete mode 100644 crates/wflpkg/src/commands/check.rs delete mode 100644 crates/wflpkg/src/commands/create.rs delete mode 100644 crates/wflpkg/src/commands/info.rs delete mode 100644 crates/wflpkg/src/commands/login.rs delete mode 100644 crates/wflpkg/src/commands/mod.rs delete mode 100644 crates/wflpkg/src/commands/remove.rs delete mode 100644 crates/wflpkg/src/commands/run.rs delete mode 100644 crates/wflpkg/src/commands/search.rs delete mode 100644 crates/wflpkg/src/commands/share.rs delete mode 100644 crates/wflpkg/src/commands/update.rs delete mode 100644 crates/wflpkg/src/error.rs delete mode 100644 crates/wflpkg/src/lib.rs delete mode 100644 crates/wflpkg/src/lockfile/mod.rs delete mode 100644 crates/wflpkg/src/lockfile/parser.rs delete mode 100644 crates/wflpkg/src/lockfile/writer.rs delete mode 100644 crates/wflpkg/src/main.rs delete mode 100644 crates/wflpkg/src/manifest/mod.rs delete mode 100644 crates/wflpkg/src/manifest/parser.rs delete mode 100644 crates/wflpkg/src/manifest/version.rs delete mode 100644 crates/wflpkg/src/manifest/writer.rs delete mode 100644 crates/wflpkg/src/package_files.rs delete mode 100644 crates/wflpkg/src/permissions.rs delete mode 100644 crates/wflpkg/src/registry/advisory.rs delete mode 100644 crates/wflpkg/src/registry/api.rs delete mode 100644 crates/wflpkg/src/registry/auth.rs delete mode 100644 crates/wflpkg/src/registry/mod.rs delete mode 100644 crates/wflpkg/src/resolver/algorithm.rs delete mode 100644 crates/wflpkg/src/resolver/mod.rs delete mode 100644 crates/wflpkg/src/resolver/package_path.rs delete mode 100644 crates/wflpkg/src/workspace/mod.rs delete mode 100644 crates/wflpkg/src/workspace/parser.rs delete mode 100644 crates/wflpkg/tests/error_handling.rs delete mode 100644 crates/wflpkg/tests/security_tests.rs delete mode 100644 crates/wflpkg/tests/version_and_lockfile_tests.rs delete mode 100644 crates/wflpkg/tests/workflow_integration.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2758f65e..39b4e3f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,10 +114,10 @@ jobs: fi echo "panic=abort correctly rejected by the compile_error gate" - # Run tests across the WHOLE workspace (root package + wflpkg + wfl-lsp), - # so the CI aggregate is a true full-workspace baseline. Previously this - # was `cargo test` (root package only), which silently skipped wflpkg's - # tests. (integration tests have access to the release binary) + # Run tests across the WHOLE workspace (root package + wfl-lsp), so the CI + # aggregate is a true full-workspace baseline. Previously this was + # `cargo test` (root package only), which silently skipped the other + # members' tests. (integration tests have access to the release binary) - name: Run Tests run: cargo test --workspace --verbose diff --git a/AGENTS.md b/AGENTS.md index f5b5197f..d1a07308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,6 @@ When changing contribution workflow, community rules, or project authority, upda - `scripts/`: Utilities (`run_integration_tests.ps1|.sh`, `configure_lsp.ps1`, `sync-branch.sh`). - `Tools/`: Helper tools (Python scripts, WFL tools). - `Nexus/`: Experimental WFL test programs. -- `wflpkg/`: Package Manager design documents. - `wix/`: Windows Installer (MSI) configuration. - `.cursor/rules/`: Cursor IDE rules and guidelines (`wfl-rules.mdc`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 704364a0..443c256f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,37 +13,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), longer echoes request or response bodies into diagnostic logs. - Unsupported database URL errors no longer echo the full connection URL, preventing embedded credentials from being disclosed in diagnostics. -- Package filesystem operations now enforce the manifest's package-name rules, - reject symlinked cache/install roots and targets, verify canonical directory - containment before recursive deletion, and prevent archive extraction through - pre-existing symlink ancestors. -- **WFL package publishing now keeps credentials registry-scoped.** A - project-controlled `registry` setting can no longer redirect a saved token to - another origin; registry URLs are canonicalized and must use HTTPS without - userinfo, paths, queries, or fragments. -- **Package archives are created in private external temporary files** and - cleaned up automatically. Archive creation refuses existing output paths, so - a project-supplied symlink can no longer redirect `wfl share` into truncating - another file. -- **Published packages now honor root and nested `.gitignore` rules.** Ignored - logs, debug reports, `.env` files, and other local-only content are excluded - from both the archive and its checksum instead of being uploaded silently. -- **Registry credentials are written atomically with private permissions.** On - Unix, the auth directory is mode `0700` and the token file is mode `0600` - before any secret bytes are written. -- **Package integrity checks now use an explicitly versioned - `wflhash:v2:` transcript.** File records include domain, path, and content - lengths; paths use portable `/` separators; verification hashes every - extracted regular file; and publishing derives the digest from the completed - archive instead of re-reading a mutable source tree. -- **Package publishing now fails closed on unsafe inputs and resource abuse.** - Manifests and entry points must be in-project regular files, unsupported - filesystem objects and ambiguous `.gitignore` patterns are rejected, package - traversal is bounded, archives upload as bounded streams, and registry - response bodies are capped at 1 MiB. -- **Registry login supports an explicit registry address.** `wfl login - [registry]` scopes a token to that HTTPS origin, mismatched logins are - rejected, and `wfl logout` can recover malformed or incomplete credentials. - **Cyclic values no longer abort the interpreter during display, diagnostics, or isolated-module cloning.** List/object formatting now detects cycles and caps nesting depth, while deep clones preserve cycles and shared references @@ -111,6 +80,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), `No such file or directory` ### Removed +- **The `wflpkg` package manager has been removed in its entirety.** The + `crates/wflpkg` crate and its standalone `wflpkg` binary are gone, along with + everything they reached into WFL: + - The positional `wfl` subcommands `create`, `add`, `remove`, `update`, + `build`, `run`, `share`, `search`, `info`, `login`, `logout`, and `check`, + and the `PACKAGE MANAGEMENT` section of `wfl --help`. + - The `package:` import protocol. `load module from "package:my-lib"` no + longer resolves through a `packages/` directory; the string is now an + ordinary relative path and fails as one. + - The `project.wfl` / `project.lock` / `workspace.wfl` manifest formats, the + `.wflpkg` archive format, the `wflhash:v2:` package-integrity transcript, + the download cache, and the `wflhub.org` registry client and credential + store. The corresponding `[Unreleased] Security` entries have been dropped, + since they described code that never shipped a release. + - **Impact on the WFL language: none**, with one exception. The file-based + module system — `load module from "path.wfl"`, `include from "path.wfl"`, + and `export` — is untouched and fully supported. Only the `package:` prefix + is withdrawn, and no released WFL program could depend on it in practice: + resolving it required an installed `packages/` tree that only the removed + `wfl add` could produce. + - **Impact on tooling:** `wfl run ` and `wfl test ` were + handled inside the same subcommand dispatch and go with it. Use the + documented spellings `wfl ` and `wfl --test `; neither + alias was ever listed in `wfl --help` or in `Docs/`. + - **Rationale:** the package manager is being redesigned from scratch. Its + supply-chain and trust-root decisions are the hardest in the project to walk + back once published, so it was withdrawn before the first release candidate + rather than shipped and then revised. The design documents are archived, + unimplemented, under `Docs/Archive/wflpkg/`. + - **Governance (`GOVERNANCE.md` §2.2, §8):** package and registry design is a + Maintainer-only decision area. The Maintainer directed this removal and + accepted the immediate withdrawal. Recorded here so the decision is + auditable rather than implicit. - **The WFL to JavaScript transpiler has been sunset.** The `wfl --transpile` command and its `--target`, `--no-runtime`, and `--es-modules` options are gone, along with the `wfl::transpiler` library module (`JavaScriptTranspiler`, diff --git a/CLAUDE.md b/CLAUDE.md index b123e166..c8b3c78e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,6 @@ Apply it as a test on every language, docs, or tooling change: if a beginner lea - `scripts/`: Utilities (`run_integration_tests.ps1|.sh`, `configure_lsp.ps1`, `sync-branch.sh`). - `Tools/`: Helper tools (Python scripts, WFL tools). - `Nexus/`: Experimental WFL test programs. -- `wflpkg/`: Package Manager design documents. - `wix/`: Windows Installer (MSI) configuration. - `.cursor/rules/`: Cursor IDE rules and guidelines (`wfl-rules.mdc`). diff --git a/Cargo.lock b/Cargo.lock index 7a60f5a9..2b85b33e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,16 +330,6 @@ dependencies = [ "cipher 0.5.2", ] -[[package]] -name = "bstr" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" -dependencies = [ - "memchr", - "serde_core", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -609,15 +599,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "criterion" version = "0.8.2" @@ -963,32 +944,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "flume" version = "0.12.0" @@ -1174,19 +1135,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - [[package]] name = "h2" version = "0.3.27" @@ -1637,22 +1585,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1958,7 +1890,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", - "simd-adler32", ] [[package]] @@ -2555,7 +2486,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -2592,27 +2522,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rpassword" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", -] - -[[package]] -name = "rtoolbox" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "rustc-demangle" version = "0.1.28" @@ -3011,12 +2920,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "simd_cesu8" version = "1.1.1" @@ -3340,17 +3243,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -4080,7 +3972,6 @@ dependencies = [ "tokio-tungstenite 0.30.0", "uuid", "warp", - "wflpkg", "zeroize", ] @@ -4098,26 +3989,6 @@ dependencies = [ "wfl", ] -[[package]] -name = "wflpkg" -version = "0.1.0" -dependencies = [ - "chrono", - "flate2", - "ignore", - "libc", - "reqwest", - "rpassword", - "rustyline", - "serde", - "serde_json", - "sha2 0.10.9", - "tar", - "tempfile", - "tokio", - "zeroize", -] - [[package]] name = "whoami" version = "2.1.2" @@ -4234,15 +4105,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4340,16 +4202,6 @@ dependencies = [ "time", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "yasna" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 9de8ab5d..cff06685 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,7 @@ executable = "wfl" [workspace] members = [ ".", - "wfl-lsp", - "crates/wflpkg" + "wfl-lsp" ] # `fuzz/` is a standalone cargo-fuzz workspace (nightly + libFuzzer only); keep # it out of the stable-toolchain root build/test. @@ -50,7 +49,6 @@ maintainer-scripts = "debian/" conf-files = ["/etc/wfl/wfl.cfg"] [dependencies] -wflpkg = { path = "crates/wflpkg" } logos = "0.16.1" rand = "0.10.2" regex = "1.13.0" diff --git a/Dev diary/2026-07-26-remove-wflpkg-system.md b/Dev diary/2026-07-26-remove-wflpkg-system.md new file mode 100644 index 00000000..e0d4b1d8 --- /dev/null +++ b/Dev diary/2026-07-26-remove-wflpkg-system.md @@ -0,0 +1,169 @@ +# 2026-07-26 — Removing the `wflpkg` package manager + +## Why + +WFL is heading into its first release candidate. The `wflpkg` package manager — +a ~7,700-line crate, twelve `wfl` subcommands, a `package:` import protocol, and +seven design documents — is being rethought from scratch. + +The deciding argument was irreversibility. Most of what ships in an RC can be +revised in the next release. A package manager cannot: manifest formats, +lockfile formats, archive formats, and above all the registry trust root are +things other people build against the moment they exist. Shipping a +half-formed one and then redesigning it would mean either breaking every early +package or carrying a design nobody wanted. Withdrawing it before the RC costs +nothing, because nothing depends on it yet. + +The docs had already drifted ahead of this decision: `Docs/guides/faq.md` and +`Docs/01-introduction/key-features.md` both told users WFL has no package +manager while the code shipped one. This change makes the code agree with the +docs rather than the other way around. + +## What was removed + +**Implementation.** The whole `crates/wflpkg` crate: manifest and lockfile +parsers, the version-constraint resolver, the `.wflpkg` tar.gz archive format, +the `wflhash:v2:` integrity transcript, the download cache, the `wflhub.org` +registry client with its credential store, the permissions model, and the +standalone `wflpkg` binary. With it went five dependencies the rest of the tree +never used — `rpassword`, `flate2`, `tar`, `ignore`, and unix `libc` — plus +`reqwest`'s `multipart` feature. + +**CLI.** The positional-subcommand dispatch block in `src/main.rs` (`create`, +`add`, `remove`, `update`, `build`, `run`, `share`, `search`, `info`, `login`, +`logout`, `check`), the `DEFAULT_REGISTRY` constant, the +`parse_create_project_args` helper, and the `PACKAGE MANAGEMENT` section of +`wfl --help`. + +**Language surface.** The `package:` prefix in +`Interpreter::resolve_module_path`, along with `resolve_package_path` and +`find_project_root`. `load module from "package:my-lib"` is now an ordinary +relative path and fails as one. + +**Docs.** The `### Package System (V4)` block in +`Docs/04-advanced-features/modules.md`, and the eight `[Unreleased] Security` +CHANGELOG bullets describing package publishing, registry credentials, and +archive integrity — all of which described code that will never ship a release. + +## What was deliberately kept + +**The module system.** `load module from "path.wfl"`, `include from "path.wfl"`, +and `export` are a general file-based feature that `package:` was bolted onto at +runtime. The parser (`src/parser/stmt/module.rs`) treats the path string as +opaque, so nothing above the interpreter needed to change. Two regression tests +pin this. + +**The design documents.** Moved with `git mv` to `Docs/Archive/wflpkg/` so the +redesign starts with the prior art rather than a blank page, and given a README +that says in its first line that nothing in the folder describes shipping +behavior. Archived aspirational specs are exactly the kind of thing that gets +mistaken for documentation later. + +`wflhub_language_gaps_prd.md` needed the most thought. It is nominally a +registry document, but what it actually specifies is a list of WFL language +capabilities — HTTP header access, response streaming — that a registry +*happened* to need. Several have since been implemented independently +(`tests/header_access_runtime_test.rs`, `tests/http_server_streaming_test.rs`). +The archive README calls this out so the language wishlist isn't assumed dead +along with the package manager. + +**The historical record.** Dev diary entries and the PR-641 red-chronology doc +that mention `wflpkg` were left untouched. They are records of work as it +happened; editing them to hide a since-removed subsystem would falsify the audit +trail. + +## The one backward-compatibility break + +`wfl run .wfl` and `wfl test .wfl` lived *inside* the package +subcommand block — they stripped the subcommand and fell through to normal file +handling. Removing the block removed them. + +This was raised with the Maintainer before implementation and the removal was +confirmed. The mitigating facts: neither alias appeared in `wfl --help`, in +`CLAUDE.md`'s CLI list, or anywhere in `Docs/`; the documented spellings +`wfl ` and `wfl --test ` are unchanged and verified working. +Post-removal, `wfl run x.wfl` exits 1 with the same not-found error any missing +path produces — loud, not silent. + +The `package:` protocol is a second nominal break, but not a practical one: +resolving it required a `packages/` tree that only the removed `wfl add` could +produce. + +## Testing (R3) + +Classified R3 — it touches backward compatibility, so `testing.md` §5 puts it in +the highest class regardless of how mechanical the diff looks. + +Red first, in a test-only commit (`afe8c05`) that is an ancestor of the removal. +`tests/package_protocol_removed_test.rs`, 8 tests, of which 6 failed against the +old tree for the intended reasons and 2 were green regression guards: + +| Test | Red behavior | +|---|---| +| `package_protocol_no_longer_resolves` | the package resolved and executed | +| `package_import_failure_mentions_no_package_manager` | no failure existed to inspect | +| `bare_package_prefix_is_not_special_cased` | `"requires a package name"` diagnostic | +| `package_subcommands_are_removed` | `wfl logout` exited 0 | +| `run_and_test_positional_aliases_are_removed` | `wfl run main.wfl` ran the program | +| `help_has_no_package_management_section` | `PACKAGE MANAGEMENT` in `--help` | +| `relative_module_paths_still_work` | green (guard) | +| `relative_load_module_still_works` | green (guard) | + +One detail worth recording. The first draft of the package tests invoked the +script as a bare relative `main.wfl` and got a *different* failure than expected: +`Could not verify project directory ""`. The removed `find_project_root` walked +up from the source file's **parent**, and a relative argument gave it an empty +parent, so it bailed before ever consulting `packages/`. That would have been a +weak Red — the test would have passed for the wrong reason. Switching to an +absolute script path exercises the real resolver, which is what makes the Red +meaningful. The helper carries a comment explaining this. + +Two negative assertions guard the removal specifically: the failure text must +not contain `project.wfl`, `wfl create project`, `wfl add`, `is not installed`, +or `packages directory` (their presence would mean a wflpkg error branch +survived), and `--help` must not mention `PACKAGE MANAGEMENT`, `wflhub`, or +publishing. `package_import_failure_mentions_no_package_manager` also asserts a +non-zero exit *before* checking the message, so it can never pass vacuously by +the import succeeding again. + +### Layers run + +| Layer | Result | +|---|---| +| `cargo fmt --all -- --check` | clean | +| `cargo clippy --all-targets --all-features -- -D warnings` | clean | +| `cargo test --workspace` | 130 test binaries, **0 failures** | +| `TestPrograms/` against `target/release/wfl` | **110 passed, 0 failed**, 24 skipped | +| `python scripts/validate_docs_examples.py` | 18/18 pass | +| `python scripts/test_docs_code_blocks.py` | ran clean (survey report, not a gate) | +| `cargo check --manifest-path fuzz/Cargo.toml` | clean; `fuzz/Cargo.lock` regenerated | +| Manual CLI | `--help`, `--version`, `--test`, program run, `wfl run` break | + +### A note on the environment, not the change + +`cargo test --workspace` and `scripts/run_integration_tests.sh` both died +mid-link with `No space left on device` / `ld terminated with signal 7 [Bus +error]`. This is the `target/` growth problem `CLAUDE.md` warns about: 114 +integration-test binaries at `debuginfo=2` push `target/debug` to ~27 GB, past +this container's allowance. + +The workaround was to run the suite with `CARGO_PROFILE_DEV_DEBUG=0 +CARGO_PROFILE_TEST_DEBUG=0`, which fits comfortably and changes nothing about +which tests run or how they assert — only the richness of panic backtraces. The +`TestPrograms/` gate was then run directly against the already-built release +binary, mirroring `run_test_programs()`'s skip and expected-fail lists exactly. +Both layers are genuinely green; neither was skipped or relaxed. CI, which has +room for the full-debuginfo build, runs them unmodified. + +## Files touched + +- **Deleted:** `crates/wflpkg/` (26 files) +- **Moved:** `wflpkg/*.md`, `wflpkg_prd.md`, `wflhub_language_gaps_prd.md` → + `Docs/Archive/wflpkg/` +- **Code:** `Cargo.toml`, `Cargo.lock`, `fuzz/Cargo.lock`, `src/main.rs`, + `src/interpreter/mod.rs` +- **Tests:** `tests/package_protocol_removed_test.rs` (new) +- **Docs:** `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md`, `GOVERNANCE.md`, + `Docs/04-advanced-features/modules.md`, `Docs/Archive/wflpkg/README.md` (new) +- **Tooling:** `.github/workflows/ci.yml` (comment), + `scripts/test_docs_code_blocks.py` (dead error-string classifiers) diff --git a/Docs/04-advanced-features/modules.md b/Docs/04-advanced-features/modules.md index 0f52ee80..352281da 100644 --- a/Docs/04-advanced-features/modules.md +++ b/Docs/04-advanced-features/modules.md @@ -845,12 +845,6 @@ load module from "expensive.wfl" load module from "expensive.wfl" # Uses cached version ``` -### Package System (V4) -```text -load module from "package:http-client" -load module from "package:json-parser" -``` - ## Summary WFL's hybrid module system provides flexible code organization: diff --git a/Docs/Archive/wflpkg/README.md b/Docs/Archive/wflpkg/README.md new file mode 100644 index 00000000..99e6c82c --- /dev/null +++ b/Docs/Archive/wflpkg/README.md @@ -0,0 +1,49 @@ +# Archive: the `wflpkg` package manager (removed) + +> **Nothing in this folder describes shipping WFL behavior.** +> +> The `wflpkg` package manager was **removed from the code base in July 2026**, during +> preparation for WFL's first release candidate. The system is being rethought from +> scratch. These documents are kept as historical prior art for that redesign — they +> are **not** a specification of anything WFL does today, and they are **not** a +> commitment to what the redesign will look like. + +## What was removed + +- The `crates/wflpkg` crate (~7,700 lines): manifest and lockfile parsing, the + dependency resolver, the `.wflpkg` archive format, the download cache, the registry + client and credential store, and the `wflpkg` binary. +- The positional `wfl` subcommands: `create`, `add`, `remove`, `update`, `build`, + `run`, `share`, `search`, `info`, `login`, `logout`, and `check`. +- The `package:` import protocol — `load module from "package:my-lib"` no longer + resolves through `packages/`. It is now an ordinary, unresolvable relative path. + +The file-based module system (`load module from "path.wfl"`, `include from "path.wfl"`, +`export`) is **unaffected** and remains fully supported. + +## What is in here + +| Document | What it was | +|---|---| +| `wflpkgdesign.md` | Master design document for the package manager | +| `wflpkg-manifest-grammar-1.0.md` | Formal grammar for `project.wfl` manifests and `project.lock` | +| `wflpkg-brainstorm-results.md` | Multi-agent brainstorm output that fed the design | +| `wflpkg-open-decisions-resolved.md` | Log of resolved open design questions | +| `wflpkg-adr-001-binary-and-crate-structure.md` | ADR: separate `wflpkg` binary vs. `wfl` subcommands | +| `wflpkg_prd.md` | PRD for **WFLHub**, the proposed registry at `wflhub.org` | +| `wflhub_language_gaps_prd.md` | PRD for language features needed to build WFLHub *in WFL* | + +## A note on `wflhub_language_gaps_prd.md` + +That document is the one file here that is only partly about packaging. It specifies +general WFL language capabilities — HTTP header access, response streaming, and +similar — that happened to be motivated by building a registry. **Those language +requirements are not cancelled** by the removal of the package manager; several have +been implemented independently since. Read it as a language wishlist that happens to +have a registry-shaped rationale, not as a dead registry document. + +## Governance + +Per `GOVERNANCE.md` §8, package and registry design — including any future revival of +this work — remains a Maintainer-only decision area, because supply-chain and trust-root +choices are not reversible once published. diff --git a/wflhub_language_gaps_prd.md b/Docs/Archive/wflpkg/wflhub_language_gaps_prd.md similarity index 100% rename from wflhub_language_gaps_prd.md rename to Docs/Archive/wflpkg/wflhub_language_gaps_prd.md diff --git a/wflpkg/wflpkg-adr-001-binary-and-crate-structure.md b/Docs/Archive/wflpkg/wflpkg-adr-001-binary-and-crate-structure.md similarity index 100% rename from wflpkg/wflpkg-adr-001-binary-and-crate-structure.md rename to Docs/Archive/wflpkg/wflpkg-adr-001-binary-and-crate-structure.md diff --git a/wflpkg/wflpkg-brainstorm-results.md b/Docs/Archive/wflpkg/wflpkg-brainstorm-results.md similarity index 100% rename from wflpkg/wflpkg-brainstorm-results.md rename to Docs/Archive/wflpkg/wflpkg-brainstorm-results.md diff --git a/wflpkg/wflpkg-manifest-grammar-1.0.md b/Docs/Archive/wflpkg/wflpkg-manifest-grammar-1.0.md similarity index 100% rename from wflpkg/wflpkg-manifest-grammar-1.0.md rename to Docs/Archive/wflpkg/wflpkg-manifest-grammar-1.0.md diff --git a/wflpkg/wflpkg-open-decisions-resolved.md b/Docs/Archive/wflpkg/wflpkg-open-decisions-resolved.md similarity index 100% rename from wflpkg/wflpkg-open-decisions-resolved.md rename to Docs/Archive/wflpkg/wflpkg-open-decisions-resolved.md diff --git a/wflpkg_prd.md b/Docs/Archive/wflpkg/wflpkg_prd.md similarity index 100% rename from wflpkg_prd.md rename to Docs/Archive/wflpkg/wflpkg_prd.md diff --git a/wflpkg/wflpkgdesign.md b/Docs/Archive/wflpkg/wflpkgdesign.md similarity index 100% rename from wflpkg/wflpkgdesign.md rename to Docs/Archive/wflpkg/wflpkgdesign.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 05f57872..ff64c9f5 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -73,7 +73,7 @@ Community input is valued and routinely sought through: - GitHub Issues and Discussions - Pull request review comments -- Design notes, Dev Diary entries, and package-design ADRs under `wflpkg/` and `Dev diary/` +- Design notes and Dev Diary entries under `Dev diary/` Input is advisory unless a Maintainer adopts it. Silence is not consent for breaking changes; Maintainers still own the compatibility bar. @@ -239,13 +239,16 @@ Apache-2.0 terms. | Asset | Owner / steward | |---|---| | GitHub org `WebFirstLanguage` | Logbie LLC / Maintainers | -| Package / registry designs (`wflpkg/`, future hub) | Maintainers; supply-chain and trust-root decisions are Maintainer-only | +| Package / registry designs (future; prior art archived under `Docs/Archive/wflpkg/`) | Maintainers; supply-chain and trust-root decisions are Maintainer-only | | Domain and brand references | Logbie LLC | | Signing keys, release credentials | Maintainers only | -Design documents under `wflpkg/` may describe future registry **governance -risk** (longevity, key custody, transparency logs). Those designs do not -transfer authority away from Maintainers unless this document is amended. +WFL has no package manager. The `wflpkg` implementation was removed before the +first release candidate and the system is being redesigned from scratch; its +design documents are archived, unimplemented, under `Docs/Archive/wflpkg/`. +Those archived documents — and any future ones — may describe registry +**governance risk** (longevity, key custody, transparency logs), but they do +not transfer authority away from Maintainers unless this document is amended. --- diff --git a/crates/wflpkg/Cargo.toml b/crates/wflpkg/Cargo.toml deleted file mode 100644 index 33e5853d..00000000 --- a/crates/wflpkg/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "wflpkg" -version = "0.1.0" -edition = "2024" -description = "Package manager for the WebFirst Language (WFL)" -license = "Apache-2.0" - -[dependencies] -tokio = { version = "1.52.3", features = ["full"] } -reqwest = { version = "0.13.4", features = ["json", "multipart", "stream"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.150" -rustyline = "18.0.1" -rpassword = "7.5.4" -chrono = "0.4.45" -sha2 = "0.10" -zeroize = "1.9" -flate2 = "1.0" -tar = "0.4" -ignore = "0.4.23" -tempfile = "3.27.0" - -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -[[bin]] -name = "wflpkg" -path = "src/main.rs" diff --git a/crates/wflpkg/src/archive.rs b/crates/wflpkg/src/archive.rs deleted file mode 100644 index 77fcf5fe..00000000 --- a/crates/wflpkg/src/archive.rs +++ /dev/null @@ -1,491 +0,0 @@ -use flate2::Compression; -use flate2::read::GzDecoder; -use flate2::write::GzEncoder; -use std::io::{Read, Write}; -use std::path::Path; -use tar::{Archive, Header}; - -use crate::error::PackageError; -use crate::package_files::IgnoreStack; - -const MAX_PACKAGE_ENTRIES: u64 = 10_000; -const MAX_PACKAGE_SOURCE_BYTES: u64 = 1024 * 1024 * 1024; -const MAX_PACKAGE_DEPTH: usize = 128; - -/// Create a `.wflpkg` archive (tar.gz) from a project directory. -/// Excludes `packages/`, `.git/`, `node_modules/`, and other non-source files. -pub fn create_archive(project_dir: &Path, output_path: &Path) -> Result<(), PackageError> { - let file = create_new_private_file(output_path)?; - let mut cleanup = RemoveOnDrop::new(output_path); - create_archive_to_writer(project_dir, file)?; - cleanup.keep(); - Ok(()) -} - -/// Create an archive on a caller-owned output stream. -/// -/// The share command uses this with an already-open private temporary file, so -/// an untrusted project cannot redirect archive creation through a symlink. -pub(crate) fn create_archive_to_writer( - project_dir: &Path, - writer: W, -) -> Result<(), PackageError> { - let enc = GzEncoder::new(writer, Compression::default()); - let mut tar = tar::Builder::new(enc); - // A path that is swapped to a symlink while packaging must never cause - // tar to follow and disclose its target. - tar.follow_symlinks(false); - let mut ignore_stack = IgnoreStack::new(project_dir)?; - let mut budget = TraversalBudget::default(); - - add_dir_to_archive( - &mut tar, - project_dir, - project_dir, - &mut ignore_stack, - &mut budget, - 0, - )?; - - let enc = tar - .into_inner() - .map_err(|e| PackageError::General(format!("Failed to create archive: {}", e)))?; - enc.finish() - .map_err(|e| PackageError::General(format!("Failed to finish archive: {}", e)))?; - - Ok(()) -} - -/// Recursively add directory contents to the archive. -fn add_dir_to_archive( - tar: &mut tar::Builder, - base: &Path, - dir: &Path, - ignore_stack: &mut IgnoreStack, - budget: &mut TraversalBudget, - depth: usize, -) -> Result<(), PackageError> { - if depth > MAX_PACKAGE_DEPTH { - return Err(package_budget_error()); - } - let mut entries = Vec::new(); - for entry in std::fs::read_dir(dir)? { - budget.add_entry()?; - entries.push(entry?); - } - for entry in &entries { - if entry.file_name().to_str().is_none() { - return Err(PackageError::General(format!( - "Package path is not valid Unicode: {}", - entry.path().display() - ))); - } - } - entries.sort_by(|left, right| { - left.file_name() - .to_str() - .expect("validated above") - .cmp(right.file_name().to_str().expect("validated above")) - }); - - for entry in entries { - let path = entry.path(); - let name = entry.file_name(); - let name_str = name.to_str().expect("validated above"); - - if crate::is_excluded(name_str) { - continue; - } - - let ft = entry - .file_type() - .map_err(|e| PackageError::General(format!("Failed to read file type: {}", e)))?; - - // Skip symlinks to avoid following links outside the project - if ft.is_symlink() { - continue; - } - - let relative = path.strip_prefix(base).unwrap_or(&path); - if ignore_stack.is_ignored(relative, ft.is_dir())? { - continue; - } - if !ft.is_dir() && !ft.is_file() { - return Err(PackageError::General(format!( - "Package contains an unsupported filesystem object: {}", - path.display() - ))); - } - if ft.is_dir() { - let added_rules = ignore_stack.push_for_dir(&path, relative)?; - let result = add_dir_to_archive(tar, base, &path, ignore_stack, budget, depth + 1); - ignore_stack.pop(added_rules); - result?; - } else { - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let mut file = options.open(&path)?; - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(PackageError::General(format!( - "Package file changed type while it was being archived: {}", - path.display() - ))); - } - budget.add_bytes(metadata.len())?; - let captured_size = metadata.len(); - let mut header = Header::new_gnu(); - header.set_metadata(&metadata); - header.set_size(captured_size); - { - let mut limited = (&mut file).take(captured_size); - tar.append_data(&mut header, relative, &mut limited) - .map_err(|e| { - PackageError::General(format!("Failed to add file to archive: {}", e)) - })?; - if limited.limit() != 0 { - return Err(PackageError::General(format!( - "Package file changed while it was being archived: {}", - path.display() - ))); - } - } - let mut extra = [0_u8; 1]; - if file.read(&mut extra)? != 0 { - return Err(PackageError::General(format!( - "Package file changed while it was being archived: {}", - path.display() - ))); - } - } - } - Ok(()) -} - -#[derive(Default)] -struct TraversalBudget { - entries: u64, - source_bytes: u64, -} - -impl TraversalBudget { - fn add_entry(&mut self) -> Result<(), PackageError> { - self.entries = self - .entries - .checked_add(1) - .ok_or_else(package_budget_error)?; - if self.entries > MAX_PACKAGE_ENTRIES { - return Err(package_budget_error()); - } - Ok(()) - } - - fn add_bytes(&mut self, bytes: u64) -> Result<(), PackageError> { - self.source_bytes = self - .source_bytes - .checked_add(bytes) - .ok_or_else(package_budget_error)?; - if self.source_bytes > MAX_PACKAGE_SOURCE_BYTES { - return Err(package_budget_error()); - } - Ok(()) - } -} - -fn package_budget_error() -> PackageError { - PackageError::General( - "The project exceeds the safe package size, file-count, or directory-depth limit." - .to_string(), - ) -} - -fn create_new_private_file(path: &Path) -> Result { - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - options.open(path).map_err(PackageError::Io) -} - -struct RemoveOnDrop<'a> { - path: &'a Path, - keep: bool, -} - -impl<'a> RemoveOnDrop<'a> { - fn new(path: &'a Path) -> Self { - Self { path, keep: false } - } - - fn keep(&mut self) { - self.keep = true; - } -} - -impl Drop for RemoveOnDrop<'_> { - fn drop(&mut self) { - if !self.keep { - let _ = std::fs::remove_file(self.path); - } - } -} - -/// Extract a `.wflpkg` archive to a destination directory. -/// -/// Validates that all extracted paths stay within `dest_dir` to prevent -/// directory traversal attacks from malicious archives. -pub fn extract_archive(archive_path: &Path, dest_dir: &Path) -> Result<(), PackageError> { - let file = std::fs::File::open(archive_path)?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - - let dest_canonical = std::fs::canonicalize( - std::fs::create_dir_all(dest_dir) - .map(|_| dest_dir) - .map_err(PackageError::Io)?, - ) - .map_err(PackageError::Io)?; - - for entry in archive - .entries() - .map_err(|e| PackageError::General(format!("Failed to read archive entries: {}", e)))? - { - let mut entry = - entry.map_err(|e| PackageError::General(format!("Invalid archive entry: {}", e)))?; - - let entry_path = entry - .path() - .map_err(|e| PackageError::General(format!("Invalid entry path: {}", e)))? - .into_owned(); - - // Reject absolute paths - // `Path::is_absolute` requires a drive prefix on Windows, but archive - // paths are portable and a leading slash is still rooted there. - if entry_path.has_root() { - return Err(PackageError::General(format!( - "Archive contains absolute path: {}", - entry_path.display() - ))); - } - - // Reject paths with .. components - for component in entry_path.components() { - if matches!(component, std::path::Component::ParentDir) { - return Err(PackageError::General(format!( - "Archive contains path traversal: {}", - entry_path.display() - ))); - } - } - - // Verify resolved path stays within dest_dir - let target = dest_canonical.join(&entry_path); - if !target.starts_with(&dest_canonical) { - return Err(PackageError::General(format!( - "Archive entry escapes destination: {}", - entry_path.display() - ))); - } - - // Reject symlink and hard link entries to prevent symlink-based attacks - if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() { - return Err(PackageError::General(format!( - "Archive contains a symlink or hard link: {}", - entry_path.display() - ))); - } - - let unpacked = entry.unpack_in(&dest_canonical).map_err(|e| { - PackageError::General(format!("Failed to extract {}: {}", entry_path.display(), e)) - })?; - if !unpacked { - return Err(PackageError::General(format!( - "Archive entry escapes destination: {}", - entry_path.display() - ))); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_wflpkg_files_excluded_from_archive() { - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - std::fs::write(src.join("project.wflpkg"), b"archive data").unwrap(); - - let archive_path = temp.path().join("test.wflpkg"); - create_archive(&src, &archive_path).unwrap(); - - let dest = temp.path().join("extracted"); - extract_archive(&archive_path, &dest).unwrap(); - - assert!(dest.join("main.wfl").exists()); - assert!( - !dest.join("project.wflpkg").exists(), - ".wflpkg files should be excluded from archive" - ); - } - - #[test] - fn test_create_and_extract_archive() { - let temp = TempDir::new().unwrap(); - - // Create source project - let src = temp.path().join("project"); - std::fs::create_dir_all(src.join("src")).unwrap(); - std::fs::write( - src.join("project.wfl"), - "name is test\nversion is 26.1.1\ndescription is Test", - ) - .unwrap(); - std::fs::write(src.join("src").join("main.wfl"), "display \"hello\"").unwrap(); - - // Create directories that should be excluded - std::fs::create_dir_all(src.join("packages")).unwrap(); - std::fs::create_dir_all(src.join(".git")).unwrap(); - - // Create archive - let archive_path = temp.path().join("test.wflpkg"); - create_archive(&src, &archive_path).unwrap(); - assert!(archive_path.exists()); - - // Extract archive - let dest = temp.path().join("extracted"); - extract_archive(&archive_path, &dest).unwrap(); - - // Verify contents - assert!(dest.join("project.wfl").exists()); - assert!(dest.join("src").join("main.wfl").exists()); - // Excluded directories should not be present - assert!(!dest.join("packages").exists()); - assert!(!dest.join(".git").exists()); - } - - #[test] - fn test_create_archive_honors_gitignore_rules() { - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - std::fs::create_dir_all(src.join("nested")).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - std::fs::write( - src.join(".gitignore"), - ".env\n*.log\nsecret-dir/\n!important.log\n!nested/\n", - ) - .unwrap(); - std::fs::write(src.join(".env"), "API_TOKEN=secret").unwrap(); - std::fs::write(src.join("debug.log"), "credentials").unwrap(); - std::fs::write(src.join("important.log"), "safe asset").unwrap(); - std::fs::create_dir_all(src.join("secret-dir")).unwrap(); - std::fs::write(src.join("secret-dir/token.txt"), "secret").unwrap(); - std::fs::write(src.join("nested/debug.log"), "credentials").unwrap(); - - let archive_path = temp.path().join("test.wflpkg"); - create_archive(&src, &archive_path).unwrap(); - let dest = temp.path().join("extracted"); - extract_archive(&archive_path, &dest).unwrap(); - - assert!(dest.join("main.wfl").exists()); - assert!(dest.join("important.log").exists()); - assert!(!dest.join(".env").exists()); - assert!(!dest.join("debug.log").exists()); - assert!(!dest.join("nested/debug.log").exists()); - assert!(!dest.join("secret-dir").exists()); - } - - #[test] - fn test_create_archive_honors_nested_gitignore_rules() { - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - std::fs::create_dir_all(src.join("nested")).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - std::fs::write(src.join("nested/.gitignore"), "private.txt\n").unwrap(); - std::fs::write(src.join("nested/private.txt"), "secret").unwrap(); - std::fs::write(src.join("nested/public.txt"), "public").unwrap(); - - let archive_path = temp.path().join("test.wflpkg"); - create_archive(&src, &archive_path).unwrap(); - let dest = temp.path().join("extracted"); - extract_archive(&archive_path, &dest).unwrap(); - - assert!(!dest.join("nested/private.txt").exists()); - assert!(dest.join("nested/public.txt").exists()); - } - - #[test] - fn test_create_archive_refuses_existing_output() { - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - let archive_path = temp.path().join("existing.wflpkg"); - std::fs::write(&archive_path, "do not overwrite").unwrap(); - - assert!(create_archive(&src, &archive_path).is_err()); - assert_eq!( - std::fs::read_to_string(&archive_path).unwrap(), - "do not overwrite" - ); - } - - #[cfg(unix)] - #[test] - fn test_create_archive_refuses_symlink_output() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - let target = temp.path().join("target.txt"); - std::fs::write(&target, "do not overwrite").unwrap(); - let archive_path = temp.path().join("linked.wflpkg"); - symlink(&target, &archive_path).unwrap(); - - assert!(create_archive(&src, &archive_path).is_err()); - assert_eq!( - std::fs::read_to_string(&target).unwrap(), - "do not overwrite" - ); - } - - #[cfg(unix)] - #[test] - fn test_package_traversal_rejects_special_files() { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - let special = src.join("pipe"); - let special_c = CString::new(special.as_os_str().as_bytes()).unwrap(); - assert_eq!(unsafe { libc::mkfifo(special_c.as_ptr(), 0o600) }, 0); - - let archive_path = temp.path().join("test.wflpkg"); - assert!(create_archive(&src, &archive_path).is_err()); - assert!(!archive_path.exists()); - assert!(crate::checksum::compute_checksum(&src).is_err()); - - std::fs::write(src.join(".gitignore"), "pipe\n").unwrap(); - create_archive(&src, &archive_path).unwrap(); - assert!(crate::checksum::compute_checksum(&src).is_ok()); - } -} diff --git a/crates/wflpkg/src/cache/mod.rs b/crates/wflpkg/src/cache/mod.rs deleted file mode 100644 index 6e33ff7b..00000000 --- a/crates/wflpkg/src/cache/mod.rs +++ /dev/null @@ -1,409 +0,0 @@ -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; - -use crate::error::PackageError; -use crate::manifest::parser::validate_package_name; -use crate::manifest::version::Version; - -fn verify_directory(path: &Path, description: &str) -> Result { - let metadata = std::fs::symlink_metadata(path)?; - if metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to use {} \"{}\": symbolic links are not allowed.", - description, - path.display() - ))); - } - if !metadata.is_dir() { - return Err(PackageError::General(format!( - "Refusing to use {} \"{}\": it is not a directory.", - description, - path.display() - ))); - } - path.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify {} \"{}\": {}", - description, - path.display(), - error - )) - }) -} - -fn existing_child_directory( - parent: &Path, - child: &Path, - description: &str, -) -> Result, PackageError> { - let metadata = match std::fs::symlink_metadata(child) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - if metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to use {} \"{}\": symbolic links are not allowed.", - description, - child.display() - ))); - } - if !metadata.is_dir() { - return Err(PackageError::General(format!( - "Refusing to use {} \"{}\": it is not a directory.", - description, - child.display() - ))); - } - - let canonical_child = child.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify {} \"{}\": {}", - description, - child.display(), - error - )) - })?; - if canonical_child.parent() != Some(parent) { - return Err(PackageError::General(format!( - "Refusing to use {} \"{}\": it escapes its expected parent directory.", - description, - child.display() - ))); - } - - Ok(Some(canonical_child)) -} - -fn ensure_child_directory( - parent: &Path, - child: &Path, - description: &str, -) -> Result { - if let Some(existing) = existing_child_directory(parent, child, description)? { - return Ok(existing); - } - - match std::fs::create_dir(child) { - Ok(()) => {} - Err(error) if error.kind() == ErrorKind::AlreadyExists => {} - Err(error) => return Err(error.into()), - } - existing_child_directory(parent, child, description)?.ok_or_else(|| { - PackageError::General(format!( - "Could not create {} \"{}\".", - description, - child.display() - )) - }) -} - -/// Manages the global package cache at `~/.wfl/packages/`. -pub struct PackageCache { - cache_dir: PathBuf, -} - -impl PackageCache { - /// Create a new cache manager using the default global cache directory. - pub fn new() -> Result { - let home = dirs_home()?; - let cache_dir = home.join(".wfl").join("packages"); - Self::with_dir(cache_dir) - } - - /// Create a cache manager with a custom directory (for testing). - pub fn with_dir(cache_dir: PathBuf) -> Result { - match std::fs::symlink_metadata(&cache_dir) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(PackageError::General(format!( - "Refusing to use package cache \"{}\": symbolic links are not allowed.", - cache_dir.display() - ))); - } - Ok(metadata) if !metadata.is_dir() => { - return Err(PackageError::General(format!( - "Refusing to use package cache \"{}\": it is not a directory.", - cache_dir.display() - ))); - } - Ok(_) => {} - Err(error) if error.kind() == ErrorKind::NotFound => { - std::fs::create_dir_all(&cache_dir)?; - } - Err(error) => return Err(error.into()), - } - - Ok(Self { - cache_dir: verify_directory(&cache_dir, "package cache")?, - }) - } - - /// Get the path for a specific package version in the cache. - pub fn package_path(&self, name: &str, version: &Version) -> PathBuf { - if validate_package_name(name).is_err() { - return self - .cache_dir - .join(".invalid-package-name") - .join(version.to_string()); - } - self.cache_dir.join(name).join(version.to_string()) - } - - /// Check if a package version is cached. - pub fn is_cached(&self, name: &str, version: &Version) -> bool { - if validate_package_name(name).is_err() { - return false; - } - self.cached_package_directory(name, version) - .ok() - .flatten() - .is_some() - } - - /// Store a package in the cache by copying from a source directory. - pub fn store(&self, name: &str, version: &Version, source: &Path) -> Result<(), PackageError> { - validate_package_name(name)?; - let source = verify_directory(source, "package source")?; - let cache_root = self.verified_cache_root()?; - let package_root = ensure_child_directory( - &cache_root, - &cache_root.join(name), - "package cache directory", - )?; - let dest = package_root.join(version.to_string()); - if let Some(existing) = - existing_child_directory(&package_root, &dest, "cached package version")? - { - if existing == source { - return Err(PackageError::General( - "Refusing to replace a cached package with itself.".to_string(), - )); - } - std::fs::remove_dir_all(existing)?; - } - - copy_dir_recursive(&source, &dest)?; - Ok(()) - } - - /// Install a cached package into the project's `packages/` directory. - pub fn install_to_project( - &self, - name: &str, - version: &Version, - project_dir: &Path, - ) -> Result<(), PackageError> { - validate_package_name(name)?; - let cache_path = self - .cached_package_directory(name, version)? - .ok_or_else(|| { - PackageError::General(format!("Package {} {} is not in the cache.", name, version)) - })?; - - let project_root = project_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify project directory \"{}\": {}", - project_dir.display(), - error - )) - })?; - let packages_path = project_root.join("packages"); - let packages_root = - ensure_child_directory(&project_root, &packages_path, "project packages directory")?; - let dest = packages_root.join(name); - if let Some(existing) = - existing_child_directory(&packages_root, &dest, "installed package directory")? - { - std::fs::remove_dir_all(existing)?; - } - copy_dir_recursive(&cache_path, &dest)?; - Ok(()) - } - - /// List all cached versions of a package. - pub fn list_versions(&self, name: &str) -> Result, PackageError> { - validate_package_name(name)?; - let cache_root = self.verified_cache_root()?; - let pkg_dir = match existing_child_directory( - &cache_root, - &cache_root.join(name), - "package cache directory", - )? { - Some(path) => path, - None => return Ok(Vec::new()), - }; - - let mut versions = Vec::new(); - for entry in std::fs::read_dir(&pkg_dir)? { - let entry = entry?; - let file_type = entry.file_type()?; - if file_type.is_symlink() { - return Err(PackageError::General(format!( - "Refusing to inspect cached package version \"{}\": symbolic links are not allowed.", - entry.path().display() - ))); - } - if file_type.is_dir() - && let Ok(v) = Version::parse(&entry.file_name().to_string_lossy()) - { - versions.push(v); - } - } - versions.sort(); - Ok(versions) - } - - /// Get the cache directory path. - pub fn cache_dir(&self) -> &Path { - &self.cache_dir - } - - fn verified_cache_root(&self) -> Result { - let canonical = verify_directory(&self.cache_dir, "package cache")?; - if canonical != self.cache_dir { - return Err(PackageError::General(format!( - "Refusing to use package cache \"{}\": its filesystem location changed.", - self.cache_dir.display() - ))); - } - Ok(canonical) - } - - fn cached_package_directory( - &self, - name: &str, - version: &Version, - ) -> Result, PackageError> { - let cache_root = self.verified_cache_root()?; - let package_root = match existing_child_directory( - &cache_root, - &cache_root.join(name), - "package cache directory", - )? { - Some(path) => path, - None => return Ok(None), - }; - existing_child_directory( - &package_root, - &package_root.join(version.to_string()), - "cached package version", - ) - } -} - -/// Recursively copy a directory. -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> { - std::fs::create_dir_all(dst)?; - for entry in std::fs::read_dir(src)? { - let entry = entry?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - // Reject symlinks to prevent path-traversal from untrusted packages - let metadata = std::fs::symlink_metadata(&src_path)?; - if metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Symbolic link found in package: {}", - src_path.display() - ))); - } - - if metadata.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; - } else if metadata.is_file() { - std::fs::copy(&src_path, &dst_path)?; - } else { - return Err(PackageError::General(format!( - "Unsupported filesystem entry found in package: {}", - src_path.display() - ))); - } - } - Ok(()) -} - -/// Get the user's home directory. -fn dirs_home() -> Result { - // Use HOME env var on Unix, USERPROFILE on Windows - #[cfg(target_os = "windows")] - { - std::env::var("USERPROFILE") - .map(PathBuf::from) - .map_err(|_| PackageError::General("Could not determine home directory".to_string())) - } - #[cfg(not(target_os = "windows"))] - { - std::env::var("HOME") - .map(PathBuf::from) - .map_err(|_| PackageError::General("Could not determine home directory".to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_cache_store_and_check() { - let temp = TempDir::new().unwrap(); - let cache = PackageCache::with_dir(temp.path().join("cache")).unwrap(); - - let src = temp.path().join("src"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - - let version = Version::new(26, 1, Some(1)); - assert!(!cache.is_cached("my-pkg", &version)); - - cache.store("my-pkg", &version, &src).unwrap(); - assert!(cache.is_cached("my-pkg", &version)); - } - - #[test] - fn test_install_to_project() { - let temp = TempDir::new().unwrap(); - let cache = PackageCache::with_dir(temp.path().join("cache")).unwrap(); - - let src = temp.path().join("src"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "display \"hello\"").unwrap(); - - let version = Version::new(26, 1, Some(1)); - cache.store("my-pkg", &version, &src).unwrap(); - - let project = temp.path().join("project"); - std::fs::create_dir_all(&project).unwrap(); - cache - .install_to_project("my-pkg", &version, &project) - .unwrap(); - - assert!( - project - .join("packages") - .join("my-pkg") - .join("main.wfl") - .exists() - ); - } - - #[test] - fn test_list_versions() { - let temp = TempDir::new().unwrap(); - let cache = PackageCache::with_dir(temp.path().join("cache")).unwrap(); - - let src = temp.path().join("src"); - std::fs::create_dir_all(&src).unwrap(); - std::fs::write(src.join("main.wfl"), "// test").unwrap(); - - cache - .store("my-pkg", &Version::new(26, 1, Some(1)), &src) - .unwrap(); - cache - .store("my-pkg", &Version::new(26, 1, Some(2)), &src) - .unwrap(); - - let versions = cache.list_versions("my-pkg").unwrap(); - assert_eq!(versions.len(), 2); - } -} diff --git a/crates/wflpkg/src/checksum.rs b/crates/wflpkg/src/checksum.rs deleted file mode 100644 index 8914034d..00000000 --- a/crates/wflpkg/src/checksum.rs +++ /dev/null @@ -1,440 +0,0 @@ -use flate2::read::GzDecoder; -use sha2::{Digest, Sha256}; -use std::io::Read; -use std::path::{Component, Path}; -use tar::Archive; - -use crate::error::PackageError; -use crate::package_files::IgnoreStack; - -const CHECKSUM_PREFIX: &str = "wflhash:v2:"; -const CHECKSUM_DOMAIN: &[u8] = b"WFL package checksum\0v2\0"; - -/// Compute a v2 WFL package checksum over selected source contents. -/// -/// Publishing uses `compute_archive_checksum` after the immutable archive has -/// been created. This public helper remains useful for source trees and uses -/// the same selection rules as archive creation. -pub fn compute_checksum(path: &Path) -> Result { - let metadata = std::fs::symlink_metadata(path)?; - let mut hasher = new_hasher(); - let file_type = metadata.file_type(); - if file_type.is_symlink() { - return Err(unsupported_object(path)); - } - if file_type.is_dir() { - let mut ignore_stack = IgnoreStack::new(path)?; - hash_source_directory(path, path, &mut hasher, &mut ignore_stack)?; - } else if file_type.is_file() { - hash_file(path, path, &mut hasher)?; - } else { - return Err(unsupported_object(path)); - } - Ok(finish_checksum(hasher)) -} - -/// Compute the checksum from the completed upload archive itself. -/// -/// This prevents a concurrent source edit from producing archive A with the -/// checksum of source state B. The required entry point must be present as a -/// regular file in that exact archive. -pub(crate) fn compute_archive_checksum( - archive_path: &Path, - required_entry: &Path, -) -> Result { - let file = std::fs::File::open(archive_path)?; - let decoder = GzDecoder::new(file); - let mut archive = Archive::new(decoder); - let required = portable_relative_path(required_entry)?; - if required.is_empty() { - return Err(PackageError::General( - "The project entry point cannot be empty.".to_string(), - )); - } - - let mut hasher = new_hasher(); - let mut found_entry = false; - let mut found_manifest = false; - - for item in archive - .entries() - .map_err(|error| PackageError::General(format!("Failed to read archive: {}", error)))? - { - let mut entry = item - .map_err(|error| PackageError::General(format!("Invalid archive entry: {}", error)))?; - if !entry.header().entry_type().is_file() { - return Err(PackageError::General( - "The generated package archive contains a non-regular entry.".to_string(), - )); - } - let entry_path = entry - .path() - .map_err(|error| PackageError::General(format!("Invalid archive path: {}", error)))?; - let portable = portable_relative_path(&entry_path)?; - if portable.is_empty() { - return Err(PackageError::General( - "The generated package archive contains an empty path.".to_string(), - )); - } - found_entry |= portable == required; - found_manifest |= portable == "project.wfl"; - let size = entry.size(); - hash_record(&portable, size, &mut entry, &mut hasher)?; - } - - if !found_manifest { - return Err(PackageError::General( - "project.wfl is not included in the package archive. Check .gitignore and try again." - .to_string(), - )); - } - if !found_entry { - return Err(PackageError::General(format!( - "The entry point \"{}\" is not included in the package archive. Check .gitignore and make sure it is a regular file.", - required_entry.display() - ))); - } - Ok(finish_checksum(hasher)) -} - -/// Recursively hash source contents in deterministic order. -fn hash_source_directory( - base: &Path, - path: &Path, - hasher: &mut Sha256, - ignore_stack: &mut IgnoreStack, -) -> Result<(), PackageError> { - let entries = sorted_entries(path)?; - - for entry in entries { - let entry_path = entry.path(); - let file_name = entry.file_name(); - let name = file_name.to_str().ok_or_else(|| { - PackageError::General(format!( - "Package path is not valid Unicode: {}", - entry_path.display() - )) - })?; - - if crate::is_excluded(name) { - continue; - } - - let file_type = entry.file_type()?; - if file_type.is_symlink() { - continue; - } - let relative = entry_path.strip_prefix(base).unwrap_or(&entry_path); - if ignore_stack.is_ignored(relative, file_type.is_dir())? { - continue; - } - if !file_type.is_dir() && !file_type.is_file() { - return Err(unsupported_object(&entry_path)); - } - - if file_type.is_dir() { - let added_rules = ignore_stack.push_for_dir(&entry_path, relative)?; - let result = hash_source_directory(base, &entry_path, hasher, ignore_stack); - ignore_stack.pop(added_rules); - result?; - } else { - hash_file(base, &entry_path, hasher)?; - } - } - - Ok(()) -} - -/// Hash every regular file in an installed package. Unlike source selection, -/// verification deliberately honors neither fixed exclusions nor a package's -/// `.gitignore`, so an added payload can never be invisible to verification. -fn hash_verified_directory( - base: &Path, - path: &Path, - hasher: &mut Sha256, -) -> Result<(), PackageError> { - for entry in sorted_entries(path)? { - let entry_path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_symlink() || (!file_type.is_dir() && !file_type.is_file()) { - return Err(unsupported_object(&entry_path)); - } - if file_type.is_dir() { - hash_verified_directory(base, &entry_path, hasher)?; - } else { - hash_file(base, &entry_path, hasher)?; - } - } - Ok(()) -} - -fn sorted_entries(path: &Path) -> Result, PackageError> { - let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::, _>>()?; - for entry in &entries { - if entry.file_name().to_str().is_none() { - return Err(PackageError::General(format!( - "Package path is not valid Unicode: {}", - entry.path().display() - ))); - } - } - entries.sort_by(|left, right| { - left.file_name() - .to_str() - .expect("validated above") - .cmp(right.file_name().to_str().expect("validated above")) - }); - Ok(entries) -} - -fn hash_file(base: &Path, path: &Path, hasher: &mut Sha256) -> Result<(), PackageError> { - let path_metadata = std::fs::symlink_metadata(path)?; - if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { - return Err(unsupported_object(path)); - } - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let mut file = options.open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(unsupported_object(path)); - } - let relative = path.strip_prefix(base).unwrap_or(path); - let portable = portable_relative_path(relative)?; - hash_record(&portable, metadata.len(), &mut file, hasher)?; - - // Refuse a file that grew after its length was recorded. A short read is - // already rejected inside `hash_record`. - let mut extra = [0_u8; 1]; - if file.read(&mut extra)? != 0 { - return Err(PackageError::General(format!( - "Package file changed while it was being checksummed: {}", - path.display() - ))); - } - Ok(()) -} - -fn hash_record( - portable_path: &str, - content_len: u64, - reader: &mut R, - hasher: &mut Sha256, -) -> Result<(), PackageError> { - let path_bytes = portable_path.as_bytes(); - hasher.update([0x01]); - hasher.update((path_bytes.len() as u64).to_le_bytes()); - hasher.update(path_bytes); - hasher.update(content_len.to_le_bytes()); - - let mut remaining = content_len; - let mut buffer = [0_u8; 64 * 1024]; - while remaining > 0 { - let wanted = usize::try_from(remaining.min(buffer.len() as u64)).unwrap_or(buffer.len()); - let read = reader.read(&mut buffer[..wanted])?; - if read == 0 { - return Err(PackageError::General( - "Package file changed while it was being checksummed.".to_string(), - )); - } - hasher.update(&buffer[..read]); - remaining -= read as u64; - } - Ok(()) -} - -fn portable_relative_path(path: &Path) -> Result { - let mut portable = String::new(); - for component in path.components() { - let Component::Normal(value) = component else { - return Err(PackageError::General(format!( - "Package path is not a normalized relative path: {}", - path.display() - ))); - }; - let value = value.to_str().ok_or_else(|| { - PackageError::General(format!( - "Package path is not valid Unicode: {}", - path.display() - )) - })?; - if !portable.is_empty() { - portable.push('/'); - } - portable.push_str(value); - } - Ok(portable) -} - -fn new_hasher() -> Sha256 { - let mut hasher = Sha256::new(); - hasher.update(CHECKSUM_DOMAIN); - hasher -} - -fn finish_checksum(hasher: Sha256) -> String { - format!("{}{:x}", CHECKSUM_PREFIX, hasher.finalize()) -} - -fn unsupported_object(path: &Path) -> PackageError { - PackageError::General(format!( - "Package contains an unsupported filesystem object: {}", - path.display() - )) -} - -/// Verify an installed package against an expected checksum. -pub fn verify_checksum(path: &Path, expected: &str) -> Result { - let metadata = std::fs::symlink_metadata(path)?; - let file_type = metadata.file_type(); - if file_type.is_symlink() || (!file_type.is_dir() && !file_type.is_file()) { - return Err(unsupported_object(path)); - } - - let mut hasher = new_hasher(); - if file_type.is_dir() { - hash_verified_directory(path, path, &mut hasher)?; - } else { - hash_file(path, path, &mut hasher)?; - } - Ok(finish_checksum(hasher) == expected) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_compute_checksum() { - let temp = TempDir::new().unwrap(); - std::fs::write(temp.path().join("test.wfl"), "display \"hello\"").unwrap(); - let checksum = compute_checksum(temp.path()).unwrap(); - assert!(checksum.starts_with("wflhash:v2:")); - assert_eq!(checksum.len(), 11 + 64); - } - - #[test] - fn test_verify_checksum() { - let temp = TempDir::new().unwrap(); - std::fs::write(temp.path().join("test.wfl"), "display \"hello\"").unwrap(); - let checksum = compute_checksum(temp.path()).unwrap(); - assert!(verify_checksum(temp.path(), &checksum).unwrap()); - assert!(!verify_checksum(temp.path(), "wflhash:v2:invalid").unwrap()); - } - - #[test] - fn test_deterministic() { - let temp = TempDir::new().unwrap(); - std::fs::write(temp.path().join("a.wfl"), "store x as 1").unwrap(); - std::fs::write(temp.path().join("b.wfl"), "store y as 2").unwrap(); - assert_eq!( - compute_checksum(temp.path()).unwrap(), - compute_checksum(temp.path()).unwrap() - ); - } - - #[test] - fn record_boundaries_cannot_collide() { - let one = TempDir::new().unwrap(); - let two = TempDir::new().unwrap(); - let mut collision_payload = 1_u64.to_le_bytes().to_vec(); - collision_payload.push(b'b'); - std::fs::write(one.path().join("a"), collision_payload).unwrap(); - std::fs::write(two.path().join("a"), []).unwrap(); - std::fs::write(two.path().join("b"), []).unwrap(); - - assert_ne!( - compute_checksum(one.path()).unwrap(), - compute_checksum(two.path()).unwrap() - ); - } - - #[test] - fn verification_does_not_hide_source_exclusions() { - let temp = TempDir::new().unwrap(); - std::fs::write(temp.path().join("main.wfl"), "display \"hello\"").unwrap(); - let expected = compute_checksum(temp.path()).unwrap(); - std::fs::create_dir(temp.path().join("packages")).unwrap(); - std::fs::write(temp.path().join("packages/evil.wfl"), "display \"evil\"").unwrap(); - - assert!(!verify_checksum(temp.path(), &expected).unwrap()); - } - - #[test] - fn completed_archive_checksum_matches_strict_extracted_verification() { - let temp = TempDir::new().unwrap(); - let source = temp.path().join("source"); - std::fs::create_dir_all(source.join("a")).unwrap(); - std::fs::write(source.join("a/nested.wfl"), "display \"nested\"").unwrap(); - std::fs::write(source.join("a.txt"), "asset").unwrap(); - std::fs::write(source.join("main.wfl"), "display \"main\"").unwrap(); - std::fs::write( - source.join("project.wfl"), - "name is demo\nversion is 26.1.1\ndescription is demo\nentry is main.wfl\n", - ) - .unwrap(); - std::fs::write(source.join(".gitignore"), "secret.env\n").unwrap(); - std::fs::write(source.join("secret.env"), "TOKEN=secret").unwrap(); - - let archive = temp.path().join("package.wflpkg"); - crate::archive::create_archive(&source, &archive).unwrap(); - let checksum = compute_archive_checksum(&archive, Path::new("main.wfl")).unwrap(); - assert_eq!(checksum, compute_checksum(&source).unwrap()); - - let extracted = temp.path().join("extracted"); - crate::archive::extract_archive(&archive, &extracted).unwrap(); - assert!(verify_checksum(&extracted, &checksum).unwrap()); - } - - #[test] - fn test_wflpkg_files_excluded_from_source_checksum() { - let temp = TempDir::new().unwrap(); - std::fs::write(temp.path().join("main.wfl"), "display \"hello\"").unwrap(); - let before = compute_checksum(temp.path()).unwrap(); - std::fs::write(temp.path().join("myproject.wflpkg"), b"archive data").unwrap(); - assert_eq!(before, compute_checksum(temp.path()).unwrap()); - } - - #[test] - fn test_gitignored_files_excluded_from_source_checksum() { - let temp = TempDir::new().unwrap(); - std::fs::create_dir(temp.path().join("nested")).unwrap(); - std::fs::write(temp.path().join("main.wfl"), "display \"hello\"").unwrap(); - std::fs::write(temp.path().join(".gitignore"), ".env\n*.log\n!nested/\n").unwrap(); - let before = compute_checksum(temp.path()).unwrap(); - std::fs::write(temp.path().join(".env"), "API_TOKEN=secret").unwrap(); - std::fs::write(temp.path().join("debug.log"), "credential output").unwrap(); - std::fs::write(temp.path().join("nested/debug.log"), "credential output").unwrap(); - assert_eq!(before, compute_checksum(temp.path()).unwrap()); - } - - #[test] - fn portable_path_uses_forward_slashes() { - let path = Path::new("first").join("second").join("file.wfl"); - assert_eq!( - portable_relative_path(&path).unwrap(), - "first/second/file.wfl" - ); - } - - #[cfg(unix)] - #[test] - fn root_symlinks_and_special_files_are_rejected() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let target = temp.path().join("target"); - std::fs::write(&target, "content").unwrap(); - let link = temp.path().join("link"); - symlink(&target, &link).unwrap(); - assert!(compute_checksum(&link).is_err()); - - assert!(compute_checksum(Path::new("/dev/null")).is_err()); - } -} diff --git a/crates/wflpkg/src/commands/add.rs b/crates/wflpkg/src/commands/add.rs deleted file mode 100644 index dacf89d9..00000000 --- a/crates/wflpkg/src/commands/add.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::path::Path; - -use crate::error::PackageError; -use crate::manifest::version::VersionConstraint; -use crate::manifest::{Dependency, ProjectManifest}; - -/// Add a dependency to the project. -/// -/// Parses CLI args like: -/// `wfl add http-client` -/// `wfl add http-client 26.1 or newer` -/// `wfl add http-client 26.1 or newer for development` -pub fn add_dependency(args: &[String], project_dir: &Path) -> Result<(), PackageError> { - let manifest_path = project_dir.join("project.wfl"); - if !manifest_path.exists() { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - - let mut manifest = ProjectManifest::load(&manifest_path)?; - - // Parse the arguments - let (dep, _) = parse_add_args(args)?; - - // TODO: Check permissions from registry metadata - // TODO: Download and cache the package - - // Add to manifest - manifest.add_dependency(dep.clone()); - manifest.save(&manifest_path)?; - - // TODO: Resolve and update lock file - // TODO: Install package to packages/ - - println!("Added {} {} to project.wfl", dep.name, dep.constraint); - if dep.dev_only { - println!(" (development dependency)"); - } - - Ok(()) -} - -/// Parse add command arguments into a Dependency. -fn parse_add_args(args: &[String]) -> Result<(Dependency, bool), PackageError> { - if args.is_empty() { - return Err(PackageError::General( - "I need a package name to add.\n\n\ - Usage:\n\ - \x20 wfl add \n\ - \x20 wfl add \n\ - \x20 wfl add for development" - .to_string(), - )); - } - - let name = args[0].clone(); - - // Check for "for development" at the end - let (constraint_args, dev_only) = if args.len() >= 3 - && args[args.len() - 2] == "for" - && args[args.len() - 1] == "development" - { - (&args[1..args.len() - 2], true) - } else { - (&args[1..], false) - }; - - // Parse version constraint - let constraint = if constraint_args.is_empty() { - // Default: any version (will pick latest) - VersionConstraint::AnyVersion - } else { - let constraint_str = constraint_args.join(" "); - VersionConstraint::parse(&constraint_str)? - }; - - Ok(( - Dependency { - name, - constraint, - dev_only, - }, - dev_only, - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::version::Version; - - #[test] - fn test_parse_add_simple() { - let args = vec!["http-client".to_string()]; - let (dep, _) = parse_add_args(&args).unwrap(); - assert_eq!(dep.name, "http-client"); - assert_eq!(dep.constraint, VersionConstraint::AnyVersion); - assert!(!dep.dev_only); - } - - #[test] - fn test_parse_add_with_constraint() { - let args: Vec = "http-client 26.1 or newer" - .split_whitespace() - .map(String::from) - .collect(); - let (dep, _) = parse_add_args(&args).unwrap(); - assert_eq!(dep.name, "http-client"); - assert_eq!( - dep.constraint, - VersionConstraint::OrNewer(Version::new(26, 1, None)) - ); - } - - #[test] - fn test_parse_add_dev_dependency() { - let args: Vec = "test-runner 26.1 or newer for development" - .split_whitespace() - .map(String::from) - .collect(); - let (dep, _) = parse_add_args(&args).unwrap(); - assert_eq!(dep.name, "test-runner"); - assert!(dep.dev_only); - } - - #[test] - fn test_parse_add_empty() { - let args: Vec = vec![]; - assert!(parse_add_args(&args).is_err()); - } -} diff --git a/crates/wflpkg/src/commands/build.rs b/crates/wflpkg/src/commands/build.rs deleted file mode 100644 index f36e5fe6..00000000 --- a/crates/wflpkg/src/commands/build.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::Path; -use tokio::process::Command; - -use crate::error::PackageError; -use crate::manifest::ProjectManifest; -use crate::workspace; - -/// Build the project: ensure all dependencies are installed and validate. -pub async fn build_project(project_dir: &Path) -> Result<(), PackageError> { - // Check for workspace - let workspace_path = project_dir.join("workspace.wfl"); - if workspace_path.exists() { - return build_workspace(project_dir).await; - } - - let manifest_path = project_dir.join("project.wfl"); - if !manifest_path.exists() { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - - let manifest = ProjectManifest::load(&manifest_path)?; - - // Verify entry point exists - let entry_path = project_dir.join(manifest.entry_point()); - if !entry_path.exists() { - return Err(PackageError::General(format!( - "The entry point \"{}\" does not exist.\n\ - Update the entry field in project.wfl or create the file.", - manifest.entry_point() - ))); - } - - // TODO: Ensure all dependencies are installed from lock file - // TODO: Download missing dependencies from registry - - // Verify project can be parsed by invoking wfl --parse - let status = Command::new("wfl") - .arg("--parse") - .arg(&entry_path) - .current_dir(project_dir) - .status() - .await; - - match status { - Ok(s) if s.success() => { - println!( - "Build successful: {} {}", - manifest.name, manifest.version_string - ); - Ok(()) - } - Ok(_) => Err(PackageError::General(format!( - "Build failed: parse errors in {}", - manifest.entry_point() - ))), - Err(e) => Err(PackageError::General(format!( - "Could not run wfl to verify the build: {}\n\ - Make sure wfl is installed and in your PATH.", - e - ))), - } -} - -/// Build all members of a workspace. -async fn build_workspace(workspace_dir: &Path) -> Result<(), PackageError> { - let ws = workspace::parser::parse_workspace_file(workspace_dir)?; - - println!( - "Building workspace \"{}\" ({} members)...", - ws.name, - ws.members.len() - ); - - for member_path in &ws.members { - let member_dir = workspace_dir.join(member_path); - if !member_dir.exists() { - return Err(PackageError::WorkspaceError(format!( - "Workspace member directory does not exist: {}", - member_path - ))); - } - - println!(" Building {}...", member_path); - Box::pin(build_project(&member_dir)).await?; - } - - println!("Workspace build complete."); - Ok(()) -} diff --git a/crates/wflpkg/src/commands/check.rs b/crates/wflpkg/src/commands/check.rs deleted file mode 100644 index 2c9e5ad8..00000000 --- a/crates/wflpkg/src/commands/check.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::path::Path; -use tokio::process::Command; - -use crate::error::PackageError; -use crate::lockfile::LockFile; -use crate::manifest::ProjectManifest; -use crate::manifest::version::Version; -use crate::registry::advisory; - -/// Check for security advisories affecting project dependencies. -pub async fn check_security(project_dir: &Path) -> Result<(), PackageError> { - let manifest_path = project_dir.join("project.wfl"); - if !manifest_path.exists() { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - - let manifest = ProjectManifest::load(&manifest_path)?; - let lock_path = project_dir.join("project.lock"); - - // Collect packages to check - let packages: Vec<(String, Version)> = if lock_path.exists() { - let lock = LockFile::load(&lock_path)?; - lock.packages - .iter() - .map(|p| (p.name.clone(), p.version.clone())) - .collect() - } else { - Vec::new() - }; - - if packages.is_empty() { - println!("No locked dependencies to check."); - println!("Run 'wfl update' to generate a lock file first."); - return Ok(()); - } - - let registry_url = format!("https://{}", manifest.registry_url()); - let advisories = advisory::check_advisories(®istry_url, &packages).await?; - - if advisories.is_empty() { - println!("No security advisories found. Your dependencies are up to date."); - } else { - println!( - "I found {} security {} affecting your dependencies.\n", - advisories.len(), - if advisories.len() == 1 { - "advisory" - } else { - "advisories" - } - ); - for adv in &advisories { - println!(" {} — {}: {}", adv.package, adv.severity, adv.description); - if let Some(ref fixed) = adv.fixed_in { - println!(" Fixed in {}. Run: wfl update {}", fixed, adv.package); - } - } - println!("\nTo update all affected packages at once, run:"); - println!(" wfl update"); - } - - Ok(()) -} - -/// Check compatibility of the current package version. -/// Uses `wfl --parse` to extract the public API (action and container names). -pub async fn check_compatibility(project_dir: &Path) -> Result<(), PackageError> { - let manifest_path = project_dir.join("project.wfl"); - if !manifest_path.exists() { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - - let manifest = ProjectManifest::load(&manifest_path)?; - let entry_path = project_dir.join(manifest.entry_point()); - - if !entry_path.exists() { - return Err(PackageError::General(format!( - "The entry point \"{}\" does not exist.", - manifest.entry_point() - ))); - } - - // Use wfl --parse to verify the file parses, then do a basic scan - // of the source for action/container definitions - let status = Command::new("wfl") - .arg("--parse") - .arg(&entry_path) - .current_dir(project_dir) - .status() - .await; - - match status { - Ok(s) if !s.success() => { - return Err(PackageError::General(format!( - "Cannot check compatibility: {} has parse errors.", - manifest.entry_point() - ))); - } - Err(e) => { - return Err(PackageError::General(format!("Could not run wfl: {}", e))); - } - _ => {} - } - - // Do a simple text-based scan for public API elements - let content = std::fs::read_to_string(&entry_path)?; - let mut actions = Vec::new(); - let mut containers = Vec::new(); - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("define action called ") { - let name = trimmed - .strip_prefix("define action called ") - .unwrap_or("") - .split_whitespace() - .next() - .unwrap_or(""); - if !name.is_empty() { - actions.push(name.to_string()); - } - } else if trimmed.starts_with("create container called ") { - let name = trimmed - .strip_prefix("create container called ") - .unwrap_or("") - .split_whitespace() - .next() - .unwrap_or(""); - if !name.is_empty() { - containers.push(name.to_string()); - } - } - } - - println!( - "Compatibility check for {} {}:", - manifest.name, manifest.version_string - ); - println!(); - println!(" Public API:"); - if !actions.is_empty() { - println!(" Actions: {}", actions.join(", ")); - } - if !containers.is_empty() { - println!(" Containers: {}", containers.join(", ")); - } - if actions.is_empty() && containers.is_empty() { - println!(" (no public actions or containers found)"); - } - - // TODO: Compare with previous published version from registry - println!(); - println!("Note: Full compatibility comparison with the previously published version"); - println!("will be available once the registry is live."); - - Ok(()) -} diff --git a/crates/wflpkg/src/commands/create.rs b/crates/wflpkg/src/commands/create.rs deleted file mode 100644 index 43a3e3d2..00000000 --- a/crates/wflpkg/src/commands/create.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::error::PackageError; -use crate::manifest::ProjectManifest; - -/// Create a new WFL project, either interactively or with a given name. -pub fn create_project(name: Option<&str>, base_path: &Path) -> Result { - let (project_name, description, author, license) = if let Some(name) = name { - // Non-interactive mode - ( - name.to_string(), - "A new WFL project".to_string(), - String::new(), - "MIT".to_string(), - ) - } else { - // Interactive wizard - run_wizard()? - }; - - // Validate name - validate_project_name(&project_name)?; - - // Create project directory - let project_dir = base_path.join(&project_name); - if project_dir.exists() { - return Err(PackageError::General(format!( - "A directory called \"{}\" already exists.\n\n\ - Choose a different name, or delete the existing directory first.", - project_name - ))); - } - - std::fs::create_dir_all(project_dir.join("src"))?; - - // Create project.wfl - let mut manifest = ProjectManifest { - name: project_name.clone(), - version_string: default_version(), - description, - license: Some(license), - entry: Some("src/main.wfl".to_string()), - ..Default::default() - }; - - if !author.is_empty() { - manifest.authors = vec![author]; - } - - manifest.save(&project_dir.join("project.wfl"))?; - - // Create .wflcfg - let wflcfg_content = "# WebFirst Language Configuration\n\ -# Created by wfl create project\n\n\ -timeout_seconds = 60\n\ -logging_enabled = false\n" - .to_string(); - std::fs::write(project_dir.join(".wflcfg"), wflcfg_content)?; - - // Create src/main.wfl - let main_content = format!( - "// {} - {}\n\n\ -display \"Hello from {}!\"\n", - project_name, manifest.description, project_name, - ); - std::fs::write(project_dir.join("src").join("main.wfl"), main_content)?; - - // Create .gitignore - let gitignore_content = "packages/\n*.log\nwfl-debug-report-*.txt\n"; - std::fs::write(project_dir.join(".gitignore"), gitignore_content)?; - - println!( - "Created project \"{}\" at {}", - project_name, - project_dir.display() - ); - println!(); - println!("To get started:"); - println!(" cd {}", project_name); - println!(" wfl run"); - - Ok(project_dir) -} - -/// Run the interactive project creation wizard. -fn run_wizard() -> Result<(String, String, String, String), PackageError> { - use rustyline::DefaultEditor; - - let mut editor = DefaultEditor::new() - .map_err(|e| PackageError::General(format!("Failed to start wizard: {}", e)))?; - - println!(); - println!("WFL Project Creation Wizard"); - println!("==========================="); - println!(); - println!("I will help you create a new WFL project."); - println!("Press Enter to accept the default value shown in brackets."); - println!(); - - // Project name - let name = loop { - let input = editor - .readline("Project name: ") - .map_err(|e| PackageError::General(format!("Input error: {}", e)))?; - let input = input.trim().to_string(); - if input.is_empty() { - println!(" A project name is required."); - continue; - } - if let Err(e) = validate_project_name(&input) { - println!(" {}", e); - continue; - } - break input; - }; - - // Description - let description = editor - .readline("Description [A new WFL project]: ") - .map_err(|e| PackageError::General(format!("Input error: {}", e)))?; - let description = if description.trim().is_empty() { - "A new WFL project".to_string() - } else { - description.trim().to_string() - }; - - // Author - let author = editor - .readline("Author (optional): ") - .map_err(|e| PackageError::General(format!("Input error: {}", e)))?; - let author = author.trim().to_string(); - - // License - let license = editor - .readline("License [MIT]: ") - .map_err(|e| PackageError::General(format!("Input error: {}", e)))?; - let license = if license.trim().is_empty() { - "MIT".to_string() - } else { - license.trim().to_string() - }; - - Ok((name, description, author, license)) -} - -/// Validate a project name (same rules as package names). -fn validate_project_name(name: &str) -> Result<(), PackageError> { - if name.is_empty() || name.len() > 64 { - return Err(PackageError::InvalidPackageName(name.to_string())); - } - - let first = name.chars().next().unwrap(); - if !first.is_ascii_lowercase() { - return Err(PackageError::InvalidPackageName(name.to_string())); - } - - for c in name.chars() { - if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' { - return Err(PackageError::InvalidPackageName(name.to_string())); - } - } - - Ok(()) -} - -/// Get the default version based on current date. -fn default_version() -> String { - let now = chrono::Local::now(); - format!("{}.{}.1", now.format("%y"), now.format("%-m")) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_create_project_non_interactive() { - let temp = TempDir::new().unwrap(); - let result = create_project(Some("test-app"), temp.path()); - assert!(result.is_ok()); - - let project_dir = result.unwrap(); - assert!(project_dir.join("project.wfl").exists()); - assert!(project_dir.join("src/main.wfl").exists()); - assert!(project_dir.join(".wflcfg").exists()); - assert!(project_dir.join(".gitignore").exists()); - - // Verify manifest can be parsed - let manifest = ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert_eq!(manifest.name, "test-app"); - assert_eq!(manifest.entry, Some("src/main.wfl".to_string())); - } - - #[test] - fn test_create_project_already_exists() { - let temp = TempDir::new().unwrap(); - std::fs::create_dir(temp.path().join("existing-app")).unwrap(); - let result = create_project(Some("existing-app"), temp.path()); - assert!(result.is_err()); - } - - #[test] - fn test_validate_project_name() { - assert!(validate_project_name("my-app").is_ok()); - assert!(validate_project_name("a").is_ok()); - assert!(validate_project_name("MyApp").is_err()); - assert!(validate_project_name("").is_err()); - assert!(validate_project_name("1app").is_err()); - } -} diff --git a/crates/wflpkg/src/commands/info.rs b/crates/wflpkg/src/commands/info.rs deleted file mode 100644 index 94e00a73..00000000 --- a/crates/wflpkg/src/commands/info.rs +++ /dev/null @@ -1,41 +0,0 @@ -use crate::error::PackageError; -use crate::registry::api::RegistryClient; - -/// Show detailed information about a package from the registry. -pub async fn show_package_info(name: &str, registry_url: &str) -> Result<(), PackageError> { - let client = RegistryClient::new(&format!("https://{}", registry_url))?; - let info = client.get_package_info(name).await?; - - println!("{} ({})", info.name, info.latest_version); - println!(); - if !info.description.is_empty() { - println!(" {}", info.description); - println!(); - } - if !info.author.is_empty() { - println!(" Author: {}", info.author); - } - if !info.license.is_empty() { - println!(" License: {}", info.license); - } - if info.downloads > 0 { - println!(" Downloads: {}", info.downloads); - } - - if !info.versions.is_empty() { - println!(); - println!(" Versions:"); - for version in info.versions.iter().rev().take(10) { - println!(" {}", version); - } - if info.versions.len() > 10 { - println!(" ... and {} more", info.versions.len() - 10); - } - } - - println!(); - println!("To add this package, run:"); - println!(" wfl add {}", name); - - Ok(()) -} diff --git a/crates/wflpkg/src/commands/login.rs b/crates/wflpkg/src/commands/login.rs deleted file mode 100644 index 03830cbc..00000000 --- a/crates/wflpkg/src/commands/login.rs +++ /dev/null @@ -1,211 +0,0 @@ -use crate::error::PackageError; -use crate::registry::auth::{AuthManager, normalize_registry_origin}; - -/// Default token reader that uses rpassword to hide input. -fn default_token_reader(prompt: &str) -> Result { - rpassword::prompt_password(prompt) - .map_err(|e| PackageError::General(format!("Input error: {}", e))) -} - -/// Log in to the registry. -pub fn login(registry_url: &str) -> Result<(), PackageError> { - login_with_reader(registry_url, &AuthManager::new()?, default_token_reader) -} - -/// Log in to the registry with an injectable token reader (for testability). -fn login_with_reader( - registry_url: &str, - auth: &AuthManager, - reader: F, -) -> Result<(), PackageError> -where - F: FnOnce(&str) -> Result, -{ - let registry_origin = normalize_registry_origin(registry_url)?; - if let Some(credentials) = auth.get_credentials()? { - if credentials.registry_origin() != registry_origin { - return Err(PackageError::General(format!( - "You are logged in to {}, not {}. Run `wfl logout`, verify the registry address, then log in again.", - credentials.registry_origin(), - registry_origin - ))); - } - println!( - "You are already logged in to {}.", - credentials.registry_origin() - ); - println!("To log out first, run: wfl logout"); - return Ok(()); - } - - println!("Logging in to {}...", registry_origin); - println!(); - - // For now, use a simple token-based login via CLI prompt. - // In the future, this will use browser-based OAuth. - println!( - "Visit {}/settings/tokens to generate an API token.", - registry_origin - ); - println!(); - - let token = reader("Enter your API token: ")?; - - let token = token.trim(); - if token.is_empty() { - return Err(PackageError::General("No token provided.".to_string())); - } - - auth.store_token(token, ®istry_origin)?; - println!("Logged in successfully to {}.", registry_origin); - - Ok(()) -} - -/// Log out from the registry. -pub fn logout() -> Result<(), PackageError> { - let auth = AuthManager::new()?; - logout_with_auth(&auth) -} - -fn logout_with_auth(auth: &AuthManager) -> Result<(), PackageError> { - if !auth.credentials_file_exists()? { - println!("You are not currently logged in."); - return Ok(()); - } - - auth.clear_token()?; - println!("Logged out successfully."); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::registry::auth::AuthManager; - use std::sync::atomic::{AtomicBool, Ordering}; - - fn temp_auth() -> (AuthManager, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("credentials.json"); - (AuthManager::with_path(path), dir) - } - - #[test] - fn test_login_calls_provided_reader() { - let (auth, _dir) = temp_auth(); - let called = AtomicBool::new(false); - let reader = |_prompt: &str| -> Result { - called.store(true, Ordering::SeqCst); - Ok("test-token-123".to_string()) - }; - - let result = login_with_reader("https://registry.example.com", &auth, reader); - assert!(result.is_ok()); - assert!( - called.load(Ordering::SeqCst), - "reader function was not called" - ); - assert!(auth.is_authenticated()); - } - - #[test] - fn test_login_empty_token_rejected() { - let (auth, _dir) = temp_auth(); - let reader = |_: &str| -> Result { Ok("".to_string()) }; - - let result = login_with_reader("https://registry.example.com", &auth, reader); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("No token provided"), "got: {}", err); - } - - #[test] - fn test_login_whitespace_token_rejected() { - let (auth, _dir) = temp_auth(); - let reader = |_: &str| -> Result { Ok(" \n".to_string()) }; - - let result = login_with_reader("https://registry.example.com", &auth, reader); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("No token provided"), "got: {}", err); - } - - #[test] - fn test_login_reader_error_propagates() { - let (auth, _dir) = temp_auth(); - let reader = |_: &str| -> Result { - Err(PackageError::General( - "Input error: device lost".to_string(), - )) - }; - - let result = login_with_reader("https://registry.example.com", &auth, reader); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("Input error: device lost"), "got: {}", err); - } - - #[test] - fn test_login_already_authenticated() { - let (auth, _dir) = temp_auth(); - auth.store_token("existing-token", "https://registry.example.com") - .unwrap(); - - let reader = |_: &str| -> Result { - panic!("reader should not be called when already authenticated"); - }; - - let result = login_with_reader("https://registry.example.com", &auth, reader); - assert!(result.is_ok()); - } - - #[test] - fn test_login_rejects_different_authenticated_registry() { - let (auth, _dir) = temp_auth(); - auth.store_token("existing-token", "https://registry.example.com") - .unwrap(); - - let result = login_with_reader("https://other.example.com", &auth, |_| { - panic!("reader should not be called for existing credentials") - }); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("not https://other.example.com") - ); - } - - #[test] - fn test_logout_recovers_malformed_auth_then_allows_login() { - let (auth, dir) = temp_auth(); - let path = dir.path().join("credentials.json"); - - for malformed in [ - "not json", - r#"{"token":"secret"}"#, - r#"{"token":"secret","registry":"http://registry.example"}"#, - ] { - std::fs::write(&path, malformed).unwrap(); - logout_with_auth(&auth).unwrap(); - assert!(!path.exists()); - login_with_reader("registry.example", &auth, |_| Ok("replacement".to_string())) - .unwrap(); - auth.clear_token().unwrap(); - } - } - - #[test] - fn test_login_trims_token() { - let (auth, _dir) = temp_auth(); - let reader = |_: &str| -> Result { Ok(" tok123 ".to_string()) }; - - let result = login_with_reader("https://registry.example.com", &auth, reader); - assert!(result.is_ok()); - let loaded = auth.get_token().unwrap().unwrap(); - assert_eq!(loaded, "tok123"); - } -} diff --git a/crates/wflpkg/src/commands/mod.rs b/crates/wflpkg/src/commands/mod.rs deleted file mode 100644 index 6a86cf41..00000000 --- a/crates/wflpkg/src/commands/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod add; -pub mod build; -pub mod check; -pub mod create; -pub mod info; -pub mod login; -pub mod remove; -pub mod run; -pub mod search; -pub mod share; -pub mod update; diff --git a/crates/wflpkg/src/commands/remove.rs b/crates/wflpkg/src/commands/remove.rs deleted file mode 100644 index 51b19d13..00000000 --- a/crates/wflpkg/src/commands/remove.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; - -use crate::error::PackageError; -use crate::manifest::ProjectManifest; -use crate::manifest::parser::validate_package_name; - -fn directory_to_remove( - name: &str, - canonical_project: &Path, -) -> Result, PackageError> { - let packages_dir = canonical_project.join("packages"); - let packages_metadata = match std::fs::symlink_metadata(&packages_dir) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - - if packages_metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": the packages directory \"{}\" is a symbolic link.", - name, - packages_dir.display() - ))); - } - if !packages_metadata.is_dir() { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": \"{}\" is not a directory.", - name, - packages_dir.display() - ))); - } - - let canonical_packages = packages_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify packages directory \"{}\": {}", - packages_dir.display(), - error - )) - })?; - if canonical_packages.parent() != Some(canonical_project) { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": packages directory escapes the project.", - name - ))); - } - - let package_dir = packages_dir.join(name); - let package_metadata = match std::fs::symlink_metadata(&package_dir) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), - }; - - if package_metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": package directory \"{}\" is a symbolic link.", - name, - package_dir.display() - ))); - } - if !package_metadata.is_dir() { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": \"{}\" is not a directory.", - name, - package_dir.display() - ))); - } - - let canonical_package = package_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify package directory \"{}\": {}", - package_dir.display(), - error - )) - })?; - if canonical_package.parent() != Some(canonical_packages.as_path()) { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": package directory escapes the packages directory.", - name - ))); - } - - Ok(Some(canonical_package)) -} - -/// Remove a dependency from the project. -pub fn remove_dependency(name: &str, project_dir: &Path) -> Result<(), PackageError> { - validate_package_name(name)?; - - let requested_manifest_path = project_dir.join("project.wfl"); - let manifest_metadata = match std::fs::symlink_metadata(&requested_manifest_path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - Err(error) => return Err(error.into()), - }; - if manifest_metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": project manifest \"{}\" is a symbolic link.", - name, - requested_manifest_path.display() - ))); - } - if !manifest_metadata.is_file() { - return Err(PackageError::General(format!( - "Refusing to remove package \"{}\": project manifest \"{}\" is not a regular file.", - name, - requested_manifest_path.display() - ))); - } - let canonical_project = project_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify project directory \"{}\": {}", - project_dir.display(), - error - )) - })?; - let manifest_path = canonical_project.join("project.wfl"); - - let mut manifest = ProjectManifest::load(&manifest_path)?; - - if manifest.find_dependency(name).is_none() { - return Err(PackageError::General(format!( - "The package \"{}\" is not listed as a dependency in your project.wfl.\n\n\ - To see your current dependencies, open project.wfl and look for\n\ - lines starting with \"requires\".", - name - ))); - } - - // Resolve and verify every component before changing the manifest. In - // particular, never pass a caller-controlled or symlinked path to the - // recursive remover. - let package_dir = directory_to_remove(name, &canonical_project)?; - - manifest.remove_dependency(name); - manifest.save(&manifest_path)?; - - // Clean up local packages directory - if let Some(package_dir) = package_dir { - std::fs::remove_dir_all(package_dir)?; - } - - // TODO: Update lock file - - println!("Removed {} from project.wfl", name); - - Ok(()) -} diff --git a/crates/wflpkg/src/commands/run.rs b/crates/wflpkg/src/commands/run.rs deleted file mode 100644 index f41a3e7e..00000000 --- a/crates/wflpkg/src/commands/run.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::path::Path; -use tokio::process::Command; - -use crate::error::PackageError; -use crate::manifest::ProjectManifest; - -/// Run the project's entry point by invoking `wfl`. -pub async fn run_project(project_dir: &Path) -> Result<(), PackageError> { - let manifest_path = project_dir.join("project.wfl"); - if !manifest_path.exists() { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - - let manifest = ProjectManifest::load(&manifest_path)?; - let entry_path = project_dir.join(manifest.entry_point()); - - if !entry_path.exists() { - return Err(PackageError::General(format!( - "The entry point \"{}\" does not exist.\n\ - Update the entry field in project.wfl or create the file.", - manifest.entry_point() - ))); - } - - let canon_project = project_dir.canonicalize().map_err(|e| { - PackageError::General(format!("Could not canonicalize project directory: {}", e)) - })?; - let canon_entry = entry_path - .canonicalize() - .map_err(|e| PackageError::General(format!("Could not canonicalize entry point: {}", e)))?; - if !canon_entry.starts_with(&canon_project) { - return Err(PackageError::General(format!( - "The entry point \"{}\" resolves outside the project directory.", - manifest.entry_point() - ))); - } - - let status = Command::new("wfl") - .arg(&canon_entry) - .current_dir(project_dir) - .status() - .await; - - match status { - Ok(s) if s.success() => Ok(()), - Ok(s) => Err(PackageError::General(format!( - "wfl exited with status code {}", - s.code().unwrap_or(1) - ))), - Err(e) => Err(PackageError::General(format!( - "Could not run wfl: {}\n\ - Make sure wfl is installed and in your PATH.", - e - ))), - } -} diff --git a/crates/wflpkg/src/commands/search.rs b/crates/wflpkg/src/commands/search.rs deleted file mode 100644 index bd531ab1..00000000 --- a/crates/wflpkg/src/commands/search.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::error::PackageError; -use crate::registry::api::RegistryClient; - -/// Search the registry for packages matching a query. -pub async fn search_packages(query: &str, registry_url: &str) -> Result<(), PackageError> { - let client = RegistryClient::new(&format!("https://{}", registry_url))?; - let results = client.search(query).await?; - - if results.is_empty() { - println!("No packages found matching \"{}\".", query); - return Ok(()); - } - - println!("Packages matching \"{}\":\n", query); - for result in &results { - println!( - " {} ({}) — {}", - result.name, result.version, result.description - ); - if result.downloads > 0 { - println!(" {} downloads", result.downloads); - } - } - println!("\nFound {} packages.", results.len()); - - Ok(()) -} diff --git a/crates/wflpkg/src/commands/share.rs b/crates/wflpkg/src/commands/share.rs deleted file mode 100644 index cf2b259c..00000000 --- a/crates/wflpkg/src/commands/share.rs +++ /dev/null @@ -1,289 +0,0 @@ -use std::path::{Component, Path, PathBuf}; - -use crate::archive; -use crate::checksum; -use crate::error::PackageError; -use crate::manifest::ProjectManifest; -use crate::manifest::version::Version; -use crate::registry::api::RegistryClient; -use crate::registry::auth::{AuthManager, normalize_registry_origin}; - -const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; - -/// Share (publish) the current project to the registry. -pub async fn share_package(project_dir: &Path) -> Result<(), PackageError> { - let project_dir = std::fs::canonicalize(project_dir)?; - let project_dir = project_dir.as_path(); - let manifest_path = project_dir.join("project.wfl"); - let manifest = load_manifest_no_follow(&manifest_path, project_dir)?; - let version = Version::parse(&manifest.version_string)?; - - // Check authentication - let auth = AuthManager::new()?; - let credentials = auth - .get_credentials()? - .ok_or(PackageError::NotAuthenticated)?; - let registry_origin = - registry_for_credentials(manifest.registry_url(), credentials.registry_origin())?; - - // Validate manifest - if manifest.name.is_empty() || manifest.description.is_empty() { - return Err(PackageError::General( - "Your project.wfl must have a name and description before sharing.".to_string(), - )); - } - - // Reject absolute/traversing/symlink/directory entry points before doing - // any packaging work. Presence in the completed archive is checked below - // as the authoritative validation against ignore rules and races. - let entry_point = validate_entry_point(project_dir, manifest.entry_point())?; - - println!("Preparing to share {} {}...", manifest.name, version); - - // Create the upload in a private, external temporary directory. Keeping - // the TempDir alive provides automatic cleanup on every return path and - // prevents a project-controlled symlink from redirecting archive output. - let archive_file = create_upload_archive(project_dir)?; - - // Derive the checksum from the completed immutable archive. This also - // verifies that the declared regular entry point is actually included. - let checksum = checksum::compute_archive_checksum(archive_file.path(), &entry_point)?; - - // Upload to registry - let mut client = RegistryClient::new(®istry_origin)?; - client.set_auth_token(credentials.token().to_string()); - - client - .publish(&manifest.name, &version, archive_file.path(), &checksum) - .await?; - - println!( - "Shared {} {} to {}", - manifest.name, version, registry_origin - ); - - Ok(()) -} - -fn load_manifest_no_follow( - manifest_path: &Path, - project_dir: &Path, -) -> Result { - use std::io::Read; - - match std::fs::symlink_metadata(manifest_path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(PackageError::General( - "project.wfl must be a regular, non-symlink file.".to_string(), - )); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - Err(error) => return Err(PackageError::Io(error)), - } - - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let file = match options.open(manifest_path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - Err(error) => return Err(PackageError::Io(error)), - }; - let metadata = file.metadata()?; - if !metadata.is_file() || metadata.len() > MAX_MANIFEST_BYTES { - return Err(PackageError::General( - "project.wfl must be a regular file no larger than 1 MiB.".to_string(), - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take(MAX_MANIFEST_BYTES + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_MANIFEST_BYTES { - return Err(PackageError::General( - "project.wfl must be no larger than 1 MiB.".to_string(), - )); - } - let content = String::from_utf8(bytes) - .map_err(|_| PackageError::General("project.wfl must contain valid UTF-8.".to_string()))?; - crate::manifest::parser::parse_manifest(&content) -} - -fn validate_entry_point(project_dir: &Path, entry: &str) -> Result { - let entry_path = Path::new(entry); - if entry_path.as_os_str().is_empty() || entry_path.is_absolute() { - return Err(invalid_entry_point(entry)); - } - for component in entry_path.components() { - if !matches!(component, Component::Normal(_)) { - return Err(invalid_entry_point(entry)); - } - } - - let metadata = std::fs::symlink_metadata(project_dir.join(entry_path)).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - invalid_entry_point(entry) - } else { - PackageError::Io(error) - } - })?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(invalid_entry_point(entry)); - } - Ok(entry_path.to_path_buf()) -} - -fn invalid_entry_point(entry: &str) -> PackageError { - PackageError::General(format!( - "The entry point \"{}\" must be a normalized relative path to a regular, non-symlink file inside the project.", - entry - )) -} - -fn registry_for_credentials( - manifest_registry: &str, - authenticated_registry: &str, -) -> Result { - let requested_origin = normalize_registry_origin(manifest_registry)?; - let authenticated_origin = normalize_registry_origin(authenticated_registry)?; - if requested_origin != authenticated_origin { - return Err(PackageError::General(format!( - "This project is configured to use {}, but your saved credentials belong to {}.\n\n\ - Refusing to send that token to a different registry. Log out, verify the project's registry setting, and log in to the intended registry.", - requested_origin, authenticated_origin - ))); - } - Ok(requested_origin) -} - -struct UploadArchive { - _directory: tempfile::TempDir, - path: std::path::PathBuf, -} - -impl UploadArchive { - fn path(&self) -> &Path { - &self.path - } -} - -fn create_upload_archive(project_dir: &Path) -> Result { - let directory = tempfile::Builder::new() - .prefix("wflpkg-upload-") - .tempdir()?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))?; - } - let path = directory.path().join("package.wflpkg"); - archive::create_archive(project_dir, &path)?; - std::fs::OpenOptions::new() - .write(true) - .open(&path)? - .sync_all()?; - Ok(UploadArchive { - _directory: directory, - path, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn credentials_are_bound_to_the_same_origin() { - assert_eq!( - registry_for_credentials("wflhub.org", "https://wflhub.org/").unwrap(), - "https://wflhub.org" - ); - assert!(registry_for_credentials("wflhub.org@evil.example", "wflhub.org").is_err()); - assert!(registry_for_credentials("evil.example", "wflhub.org").is_err()); - } - - #[test] - fn upload_archive_is_external_and_removed_on_drop() { - let project = tempfile::tempdir().unwrap(); - std::fs::write(project.path().join("main.wfl"), "display \"hello\"").unwrap(); - - let archive_path = { - let archive = create_upload_archive(project.path()).unwrap(); - assert!(!archive.path().starts_with(project.path())); - assert!(archive.path().exists()); - archive.path().to_path_buf() - }; - assert!(!archive_path.exists()); - } - - #[test] - fn entry_point_must_be_an_in_project_regular_file() { - let project = tempfile::tempdir().unwrap(); - std::fs::create_dir(project.path().join("src")).unwrap(); - std::fs::write(project.path().join("src/main.wfl"), "display \"hello\"").unwrap(); - - assert_eq!( - validate_entry_point(project.path(), "src/main.wfl").unwrap(), - Path::new("src/main.wfl") - ); - assert!(validate_entry_point(project.path(), "../outside.wfl").is_err()); - assert!(validate_entry_point(project.path(), project.path().to_str().unwrap()).is_err()); - assert!(validate_entry_point(project.path(), "src").is_err()); - } - - #[test] - fn ignored_entry_point_is_rejected_from_completed_archive() { - let project = tempfile::tempdir().unwrap(); - std::fs::create_dir(project.path().join("src")).unwrap(); - std::fs::write(project.path().join("src/main.wfl"), "display \"hello\"").unwrap(); - std::fs::write( - project.path().join("project.wfl"), - "name is demo\nversion is 26.1.1\ndescription is demo\nentry is src/main.wfl\n", - ) - .unwrap(); - std::fs::write(project.path().join(".gitignore"), "src/main.wfl\n").unwrap(); - - let entry = validate_entry_point(project.path(), "src/main.wfl").unwrap(); - let archive = create_upload_archive(project.path()).unwrap(); - assert!(checksum::compute_archive_checksum(archive.path(), &entry).is_err()); - } - - #[cfg(unix)] - #[test] - fn symlink_manifest_is_rejected() { - use std::os::unix::fs::symlink; - - let project = tempfile::tempdir().unwrap(); - let target = project.path().join("manifest-target"); - std::fs::write( - &target, - "name is demo\nversion is 26.1.1\ndescription is demo\n", - ) - .unwrap(); - let manifest = project.path().join("project.wfl"); - symlink(&target, &manifest).unwrap(); - assert!(load_manifest_no_follow(&manifest, project.path()).is_err()); - } - - #[cfg(unix)] - #[test] - fn symlink_entry_point_is_rejected() { - use std::os::unix::fs::symlink; - - let project = tempfile::tempdir().unwrap(); - std::fs::write(project.path().join("target.wfl"), "display \"hello\"").unwrap(); - symlink("target.wfl", project.path().join("main.wfl")).unwrap(); - assert!(validate_entry_point(project.path(), "main.wfl").is_err()); - } -} diff --git a/crates/wflpkg/src/commands/update.rs b/crates/wflpkg/src/commands/update.rs deleted file mode 100644 index 0cd310be..00000000 --- a/crates/wflpkg/src/commands/update.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::path::Path; - -use crate::error::PackageError; -use crate::manifest::ProjectManifest; - -/// Update dependencies to their latest compatible versions. -pub fn update_dependencies( - package_name: Option<&str>, - project_dir: &Path, -) -> Result<(), PackageError> { - let manifest_path = project_dir.join("project.wfl"); - if !manifest_path.exists() { - return Err(PackageError::ManifestNotFound( - project_dir.display().to_string(), - )); - } - - let manifest = ProjectManifest::load(&manifest_path)?; - - if let Some(name) = package_name { - // Update a specific package - if manifest.find_dependency(name).is_none() { - return Err(PackageError::General(format!( - "The package \"{}\" is not listed as a dependency.\n\n\ - To add it, run:\n\ - \x20 wfl add {}", - name, name - ))); - } - - // TODO: Query registry for latest version - // TODO: Re-resolve dependencies - // TODO: Update lock file - // TODO: Download and install updated package - - Err(PackageError::General(format!( - "Updating package \"{}\" is not yet implemented.", - name - ))) - } else { - // Update all packages - if manifest.dependencies.is_empty() { - println!("No dependencies to update."); - return Ok(()); - } - - // TODO: Query registry for latest versions - // TODO: Re-resolve all dependencies - // TODO: Update lock file - // TODO: Download and install updated packages - - Err(PackageError::General(format!( - "Dependency update is not yet implemented ({} dependencies found).", - manifest.dependencies.len() - ))) - } -} diff --git a/crates/wflpkg/src/error.rs b/crates/wflpkg/src/error.rs deleted file mode 100644 index 32e82d39..00000000 --- a/crates/wflpkg/src/error.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::fmt; - -/// Package manager error types with Elm-style first-person error messages. -#[derive(Debug)] -pub enum PackageError { - /// Manifest file not found - ManifestNotFound(String), - /// Manifest parse error - ManifestParseError { line: usize, message: String }, - /// Invalid package name - InvalidPackageName(String), - /// Invalid version string - InvalidVersion(String), - /// Invalid version constraint - InvalidVersionConstraint(String), - /// Package not found in registry - PackageNotFound { - name: String, - suggestions: Vec, - }, - /// Version conflict between dependencies - VersionConflict { - package: String, - constraint_a: String, - source_a: String, - constraint_b: String, - source_b: String, - }, - /// Registry unreachable - RegistryUnreachable(String), - /// Not authenticated - NotAuthenticated, - /// Lock file parse error - LockFileParseError { line: usize, message: String }, - /// Checksum mismatch - ChecksumMismatch { - package: String, - expected: String, - actual: String, - }, - /// IO error - Io(std::io::Error), - /// HTTP error - Http(String), - /// Security advisory found - SecurityAdvisory { - package: String, - severity: String, - description: String, - fixed_in: Option, - }, - /// Permission required - PermissionRequired { - package: String, - permissions: Vec, - }, - /// Workspace error - WorkspaceError(String), - /// General error - General(String), -} - -impl fmt::Display for PackageError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PackageError::ManifestNotFound(dir) => { - write!( - f, - "I could not find a project.wfl file in {}.\n\n\ - Every WFL project needs a project.wfl file to manage dependencies.\n\ - To create one interactively, run:\n\ - \x20 wfl create project\n\n\ - Or create one manually — here is a minimal example:\n\ - \x20 name is my-project\n\ - \x20 version is 26.1.1\n\ - \x20 description is A new WFL project", - dir - ) - } - PackageError::ManifestParseError { line, message } => { - write!( - f, - "I found a problem in your project.wfl file at line {}.\n\n{}", - line, message - ) - } - PackageError::InvalidPackageName(name) => { - write!( - f, - "The package name \"{}\" is not valid.\n\n\ - Package names must:\n\ - \x20 - Start with a lowercase letter\n\ - \x20 - Contain only lowercase letters, numbers, and hyphens\n\ - \x20 - Be between 1 and 64 characters long", - name - ) - } - PackageError::InvalidVersion(version) => { - write!( - f, - "The version \"{}\" is not a valid WFL version.\n\n\ - WFL uses calendar-based versioning: YY.MM.BUILD\n\ - For example: 26.1.1, 26.2.3, 25.12.15", - version - ) - } - PackageError::InvalidVersionConstraint(constraint) => { - write!( - f, - "I could not understand the version constraint \"{}\".\n\n\ - Valid version constraints:\n\ - \x20 26.1 or newer\n\ - \x20 26.1.3 exactly\n\ - \x20 between 25.12 and 26.2\n\ - \x20 any version\n\ - \x20 above 25.6\n\ - \x20 below 27", - constraint - ) - } - PackageError::PackageNotFound { name, suggestions } => { - let mut msg = format!( - "I could not find a package called \"{}\" in the registry.", - name - ); - if !suggestions.is_empty() { - msg.push_str("\n\nDid you mean one of these?"); - for suggestion in suggestions { - msg.push_str(&format!("\n - {}", suggestion)); - } - } - write!(f, "{}", msg) - } - PackageError::VersionConflict { - package, - constraint_a, - source_a, - constraint_b, - source_b, - } => { - write!( - f, - "I found a version conflict while resolving dependencies.\n\n\ - The package \"{}\" requires {} {},\n\ - but \"{}\" requires {} {}.\n\n\ - These two constraints cannot be satisfied at the same time.\n\n\ - You can:\n\ - \x20 1. Update \"{}\" to a version that supports {} {}:\n\ - \x20 wfl update {}\n\ - \x20 2. Remove \"{}\" if you no longer need it:\n\ - \x20 wfl remove {}", - source_a, - package, - constraint_a, - source_b, - package, - constraint_b, - source_b, - package, - constraint_a, - source_b, - source_b, - source_b, - ) - } - PackageError::RegistryUnreachable(url) => { - write!( - f, - "I could not connect to the registry at {}.\n\n\ - This might be a network issue, or the registry might be temporarily\n\ - unavailable.\n\n\ - Your project can still build using cached packages. To build offline:\n\ - \x20 wfl build\n\n\ - To retry connecting:\n\ - \x20 wfl update", - url - ) - } - PackageError::NotAuthenticated => { - write!( - f, - "I could not complete this action because you are not logged in.\n\n\ - To log in to the registry, run:\n\ - \x20 wfl login\n\n\ - Then try again." - ) - } - PackageError::LockFileParseError { line, message } => { - write!( - f, - "I found a problem in your project.lock file at line {}.\n\n\ - {}\n\n\ - You can regenerate the lock file by running:\n\ - \x20 wfl update", - line, message - ) - } - PackageError::ChecksumMismatch { - package, - expected, - actual, - } => { - write!( - f, - "The checksum for \"{}\" does not match.\n\n\ - Expected: {}\n\ - Got: {}\n\n\ - This package may have been modified or corrupted.\n\ - To re-download it, run:\n\ - \x20 wfl update {}", - package, expected, actual, package - ) - } - PackageError::Io(err) => write!(f, "I/O error: {}", err), - PackageError::Http(msg) => { - write!(f, "HTTP error: {}", msg) - } - PackageError::SecurityAdvisory { - package, - severity, - description, - fixed_in, - } => { - let mut msg = format!(" {} — {}: {}\n", package, severity, description); - if let Some(version) = fixed_in { - msg.push_str(&format!( - " Fixed in {}. Run: wfl update {}", - version, package - )); - } - write!(f, "{}", msg) - } - PackageError::PermissionRequired { - package, - permissions, - } => { - let mut msg = format!( - "The package \"{}\" needs the following permissions:\n", - package - ); - for perm in permissions { - let description = match perm.as_str() { - "file-access" => "Can read and write files on disk", - "network-access" => "Can make HTTP requests", - "system-access" => "Can execute system commands", - _ => "Unknown permission", - }; - msg.push_str(&format!(" - {}: {}\n", perm, description)); - } - msg.push_str("\nDo you want to add this package? (yes/no)"); - write!(f, "{}", msg) - } - PackageError::WorkspaceError(msg) => { - write!(f, "Workspace error: {}", msg) - } - PackageError::General(msg) => write!(f, "{}", msg), - } - } -} - -impl std::error::Error for PackageError {} - -impl From for PackageError { - fn from(err: std::io::Error) -> Self { - PackageError::Io(err) - } -} diff --git a/crates/wflpkg/src/lib.rs b/crates/wflpkg/src/lib.rs deleted file mode 100644 index 72da66d2..00000000 --- a/crates/wflpkg/src/lib.rs +++ /dev/null @@ -1,43 +0,0 @@ -pub mod archive; -pub mod cache; -pub mod checksum; -pub mod commands; -pub mod error; -pub mod lockfile; -pub mod manifest; -mod package_files; -pub mod permissions; -pub mod registry; -pub mod resolver; -pub mod workspace; - -/// Names excluded from both archive creation and checksum computation. -/// Kept in one place so the two always stay in sync. -pub const EXCLUDED_NAMES: &[&str] = &[ - "packages", - ".git", - "node_modules", - "target", - ".gitignore", - "project.lock", -]; - -/// File extensions (without leading dot) excluded from both archive creation -/// and checksum computation. -pub const EXCLUDED_EXTENSIONS: &[&str] = &["wflpkg"]; - -/// Check whether a file or directory name should be excluded from archive/checksum. -pub fn is_excluded(name: &str) -> bool { - if EXCLUDED_NAMES.contains(&name) { - return true; - } - std::path::Path::new(name) - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| EXCLUDED_EXTENSIONS.contains(&e)) -} - -/// Re-export key types for convenience. -pub use error::PackageError; -pub use manifest::ProjectManifest; -pub use manifest::version::{Version, VersionConstraint}; diff --git a/crates/wflpkg/src/lockfile/mod.rs b/crates/wflpkg/src/lockfile/mod.rs deleted file mode 100644 index 45e447a9..00000000 --- a/crates/wflpkg/src/lockfile/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -pub mod parser; -pub mod writer; - -use crate::manifest::version::Version; - -/// A lock file representing exact resolved dependency versions. -#[derive(Debug, Clone, Default)] -pub struct LockFile { - pub packages: Vec, -} - -/// A single locked package entry. -#[derive(Debug, Clone)] -pub struct LockedPackage { - pub name: String, - pub version: Version, - pub checksum: String, - pub dependencies: Vec, -} - -/// A dependency reference within a locked package. -#[derive(Debug, Clone)] -pub struct LockedDependency { - pub name: String, - pub version: Version, -} - -impl LockFile { - /// Load a lock file from a path. - pub fn load(path: &std::path::Path) -> Result { - let content = std::fs::read_to_string(path)?; - parser::parse_lock_file(&content) - } - - /// Save the lock file to a path. - pub fn save(&self, path: &std::path::Path) -> Result<(), crate::error::PackageError> { - let content = writer::write_lock_file(self); - std::fs::write(path, content)?; - Ok(()) - } - - /// Find a locked package by name. - pub fn find_package(&self, name: &str) -> Option<&LockedPackage> { - self.packages.iter().find(|p| p.name == name) - } -} diff --git a/crates/wflpkg/src/lockfile/parser.rs b/crates/wflpkg/src/lockfile/parser.rs deleted file mode 100644 index b2b86f02..00000000 --- a/crates/wflpkg/src/lockfile/parser.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::error::PackageError; -use crate::lockfile::{LockFile, LockedDependency, LockedPackage}; -use crate::manifest::version::Version; - -/// Parse a `project.lock` file from its text content. -pub fn parse_lock_file(content: &str) -> Result { - let mut packages = Vec::new(); - let mut current_package: Option = None; - for (line_num, raw_line) in content.lines().enumerate().map(|(i, l)| (i + 1, l)) { - let line = raw_line.trim_end(); - - // Skip empty lines and comments - if line.trim().is_empty() || line.trim().starts_with("//") { - continue; - } - - // Check if this is a top-level "package " line - if let Some(rest) = line.strip_prefix("package ") { - // Save previous package - if let Some(pkg) = current_package.take() { - packages.push(pkg); - } - let name = rest.trim().to_string(); - current_package = Some(LockedPackage { - name, - version: Version::new(0, 1, Some(0)), - checksum: String::new(), - dependencies: Vec::new(), - }); - continue; - } - - // Indented lines belong to the current package - if line.starts_with(" ") { - let inner = line.trim(); - if let Some(ref mut pkg) = current_package { - if let Some(rest) = inner.strip_prefix("version is ") { - pkg.version = Version::parse(rest.trim()).map_err(|_| { - PackageError::LockFileParseError { - line: line_num, - message: format!("Invalid version: {}", rest.trim()), - } - })?; - } else if let Some(rest) = inner.strip_prefix("checksum is ") { - pkg.checksum = rest.trim().to_string(); - } else if let Some(rest) = inner.strip_prefix("requires ") { - // "requires text-utils 25.11.2" - let parts: Vec<&str> = rest.trim().splitn(2, ' ').collect(); - if parts.len() == 2 { - let dep_name = parts[0].to_string(); - let dep_version = Version::parse(parts[1]).map_err(|_| { - PackageError::LockFileParseError { - line: line_num, - message: format!("Invalid dependency version: {}", parts[1]), - } - })?; - pkg.dependencies.push(LockedDependency { - name: dep_name, - version: dep_version, - }); - } else { - return Err(PackageError::LockFileParseError { - line: line_num, - message: format!( - "Malformed requires line, expected 'requires ': {}", - rest.trim() - ), - }); - } - } else { - return Err(PackageError::LockFileParseError { - line: line_num, - message: format!("Unrecognized field in package block: {}", inner), - }); - } - } else { - return Err(PackageError::LockFileParseError { - line: line_num, - message: "Found indented line without a preceding package declaration" - .to_string(), - }); - } - continue; - } - - return Err(PackageError::LockFileParseError { - line: line_num, - message: format!("Unexpected line: {}", line), - }); - } - - // Save last package - if let Some(pkg) = current_package { - packages.push(pkg); - } - - Ok(LockFile { packages }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_lock_file() { - let content = "\ -// Auto-generated by WFL. Do not edit. -// Records exact versions for reproducible builds. - -package http-client - version is 26.1.3 - checksum is wflhash:a3f8b2c9d4e5f6a7 - -package json-parser - version is 25.12.8 - checksum is wflhash:b4c5d6e7f8a9b0c1 - requires text-utils 25.11.2 - -package text-utils - version is 25.11.2 - checksum is wflhash:c5d6e7f8a9b0c1d2 -"; - let lock = parse_lock_file(content).unwrap(); - assert_eq!(lock.packages.len(), 3); - - assert_eq!(lock.packages[0].name, "http-client"); - assert_eq!(lock.packages[0].version.to_string(), "26.1.3"); - assert!(lock.packages[0].checksum.starts_with("wflhash:")); - - assert_eq!(lock.packages[1].name, "json-parser"); - assert_eq!(lock.packages[1].dependencies.len(), 1); - assert_eq!(lock.packages[1].dependencies[0].name, "text-utils"); - } -} diff --git a/crates/wflpkg/src/lockfile/writer.rs b/crates/wflpkg/src/lockfile/writer.rs deleted file mode 100644 index bed99117..00000000 --- a/crates/wflpkg/src/lockfile/writer.rs +++ /dev/null @@ -1,77 +0,0 @@ -use crate::lockfile::LockFile; - -/// Serialize a `LockFile` to the `project.lock` format. -pub fn write_lock_file(lock: &LockFile) -> String { - let mut lines = Vec::new(); - - lines.push("// Auto-generated by WFL. Do not edit.".to_string()); - lines.push("// Records exact versions for reproducible builds.".to_string()); - - for pkg in &lock.packages { - lines.push(String::new()); - lines.push(format!("package {}", pkg.name)); - lines.push(format!(" version is {}", pkg.version)); - if !pkg.checksum.is_empty() { - lines.push(format!(" checksum is {}", pkg.checksum)); - } - for dep in &pkg.dependencies { - lines.push(format!(" requires {} {}", dep.name, dep.version)); - } - } - - lines.push(String::new()); - lines.join("\n") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::lockfile::{LockedDependency, LockedPackage}; - use crate::manifest::version::Version; - - #[test] - fn test_write_lock_file() { - let lock = LockFile { - packages: vec![ - LockedPackage { - name: "http-client".to_string(), - version: Version::new(26, 1, Some(3)), - checksum: "wflhash:a3f8b2c9d4e5f6a7".to_string(), - dependencies: vec![], - }, - LockedPackage { - name: "json-parser".to_string(), - version: Version::new(25, 12, Some(8)), - checksum: "wflhash:b4c5d6e7f8a9b0c1".to_string(), - dependencies: vec![LockedDependency { - name: "text-utils".to_string(), - version: Version::new(25, 11, Some(2)), - }], - }, - ], - }; - - let output = write_lock_file(&lock); - assert!(output.contains("package http-client")); - assert!(output.contains(" version is 26.1.3")); - assert!(output.contains(" requires text-utils 25.11.2")); - } - - #[test] - fn test_roundtrip() { - let lock = LockFile { - packages: vec![LockedPackage { - name: "test-pkg".to_string(), - version: Version::new(26, 2, Some(1)), - checksum: "wflhash:abc123".to_string(), - dependencies: vec![], - }], - }; - - let output = write_lock_file(&lock); - let parsed = crate::lockfile::parser::parse_lock_file(&output).unwrap(); - assert_eq!(parsed.packages.len(), 1); - assert_eq!(parsed.packages[0].name, "test-pkg"); - assert_eq!(parsed.packages[0].version.to_string(), "26.2.1"); - } -} diff --git a/crates/wflpkg/src/main.rs b/crates/wflpkg/src/main.rs deleted file mode 100644 index d084279a..00000000 --- a/crates/wflpkg/src/main.rs +++ /dev/null @@ -1,270 +0,0 @@ -use std::env; -use std::path::Path; -use std::process; - -const DEFAULT_REGISTRY: &str = "wflhub.org"; - -/// Standalone `wflpkg` binary entry point. -/// Delegates to the same library functions used by `wfl` subcommands. -#[tokio::main] -async fn main() { - let args: Vec = env::args().collect(); - - if args.len() < 2 { - print_help(); - return; - } - - let cwd = env::current_dir().unwrap_or_else(|_| { - eprintln!("Error: Could not determine current directory."); - process::exit(1); - }); - - let result = run_command(&args[1..], &cwd).await; - if let Err(e) = result { - eprintln!("{}", e); - process::exit(1); - } -} - -async fn run_command(args: &[String], cwd: &Path) -> Result<(), wflpkg::PackageError> { - if args.is_empty() { - return Err(wflpkg::PackageError::General( - "No command provided. Run 'wflpkg help' for usage.".to_string(), - )); - } - match args[0].as_str() { - "create" => { - let name = parse_create_args(&args[1..]); - wflpkg::commands::create::create_project(name.as_deref(), cwd)?; - } - "add" => { - wflpkg::commands::add::add_dependency(&args[1..], cwd)?; - } - "remove" => { - if args.len() < 2 { - return Err(wflpkg::PackageError::General( - "Usage: wflpkg remove ".to_string(), - )); - } - wflpkg::commands::remove::remove_dependency(&args[1], cwd)?; - } - "update" => { - let pkg = if args.len() > 1 { - Some(args[1].as_str()) - } else { - None - }; - wflpkg::commands::update::update_dependencies(pkg, cwd)?; - } - "build" => { - wflpkg::commands::build::build_project(cwd).await?; - } - "run" => { - wflpkg::commands::run::run_project(cwd).await?; - } - "share" => { - wflpkg::commands::share::share_package(cwd).await?; - } - "search" => { - if args.len() < 2 { - return Err(wflpkg::PackageError::General( - "Usage: wflpkg search ".to_string(), - )); - } - wflpkg::commands::search::search_packages(&args[1], DEFAULT_REGISTRY).await?; - } - "info" => { - if args.len() < 2 { - return Err(wflpkg::PackageError::General( - "Usage: wflpkg info ".to_string(), - )); - } - wflpkg::commands::info::show_package_info(&args[1], DEFAULT_REGISTRY).await?; - } - "login" => { - wflpkg::commands::login::login(login_registry_arg(&args[1..]))?; - } - "logout" => { - wflpkg::commands::login::logout()?; - } - "check" => { - if args.len() >= 2 { - match args[1].as_str() { - "security" => { - wflpkg::commands::check::check_security(cwd).await?; - } - "compatibility" => { - wflpkg::commands::check::check_compatibility(cwd).await?; - } - _ => { - return Err(wflpkg::PackageError::General(format!( - "Unknown check type: \"{}\"\n\nValid options:\n wflpkg check security\n wflpkg check compatibility", - args[1] - ))); - } - } - } else { - return Err(wflpkg::PackageError::General( - "Usage: wflpkg check ".to_string(), - )); - } - } - "help" | "--help" | "-h" => { - print_help(); - } - other => { - return Err(wflpkg::PackageError::General(format!( - "Unknown command: \"{}\"\n\nRun 'wflpkg help' for a list of commands.", - other - ))); - } - } - - Ok(()) -} - -/// Parse "create project called " or "create project" from args. -fn parse_create_args(args: &[String]) -> Option { - // Skip "project" keyword if present - let args = if !args.is_empty() && args[0] == "project" { - &args[1..] - } else { - args - }; - - // Look for "called " - if args.len() >= 2 && args[0] == "called" { - Some(args[1].clone()) - } else if args.len() == 1 && args[0] != "called" { - // Direct name without "called" - Some(args[0].clone()) - } else { - None - } -} - -fn print_help() { - println!("WFL Package Manager (wflpkg)"); - println!(); - println!("USAGE:"); - println!(" wflpkg [args]"); - println!(); - println!("COMMANDS:"); - println!(" create [project] [called ] Create a new WFL project"); - println!(" add [constraint] Add a dependency"); - println!(" remove Remove a dependency"); - println!(" update [package] Update dependencies"); - println!(" build Build the project"); - println!(" run Run the project"); - println!(" share Share (publish) to the registry"); - println!(" search Search for packages"); - println!(" info Show package details"); - println!(" login [registry] Log in to a registry"); - println!(" logout Log out from the registry"); - println!(" check security Check for security advisories"); - println!(" check compatibility Check API compatibility"); - println!(" help Show this help message"); -} - -fn login_registry_arg(args: &[String]) -> &str { - args.first().map(String::as_str).unwrap_or(DEFAULT_REGISTRY) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn s(val: &str) -> String { - val.to_string() - } - - // --- parse_create_args tests --- - - #[test] - fn test_parse_create_args_with_called_name() { - let args = vec![s("project"), s("called"), s("my-app")]; - assert_eq!(parse_create_args(&args), Some(s("my-app"))); - } - - #[test] - fn test_parse_create_args_direct_name() { - let args = vec![s("project"), s("my-app")]; - assert_eq!(parse_create_args(&args), Some(s("my-app"))); - } - - #[test] - fn test_parse_create_args_no_name() { - let args = vec![s("project")]; - assert_eq!(parse_create_args(&args), None); - } - - #[test] - fn test_parse_create_args_empty() { - let args: Vec = vec![]; - assert_eq!(parse_create_args(&args), None); - } - - #[test] - fn test_parse_create_args_called_without_name() { - let args = vec![s("project"), s("called")]; - assert_eq!(parse_create_args(&args), None); - } - - #[test] - fn test_login_registry_arg() { - assert_eq!(login_registry_arg(&[]), DEFAULT_REGISTRY); - assert_eq!( - login_registry_arg(&[s("registry.example")]), - "registry.example" - ); - } - - // --- run_command tests --- - - #[tokio::test] - async fn test_run_command_unknown_subcommand() { - let temp = tempfile::TempDir::new().unwrap(); - let args = vec![s("bogus")]; - let result = run_command(&args, temp.path()).await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Unknown command"), - "expected 'Unknown command', got: {msg}" - ); - } - - #[tokio::test] - async fn test_run_command_remove_missing_arg() { - let temp = tempfile::TempDir::new().unwrap(); - let args = vec![s("remove")]; - let result = run_command(&args, temp.path()).await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("Usage:"), "expected 'Usage:', got: {msg}"); - } - - #[tokio::test] - async fn test_run_command_search_missing_arg() { - let temp = tempfile::TempDir::new().unwrap(); - let args = vec![s("search")]; - let result = run_command(&args, temp.path()).await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!(msg.contains("Usage:"), "expected 'Usage:', got: {msg}"); - } - - #[tokio::test] - async fn test_run_command_check_invalid_type() { - let temp = tempfile::TempDir::new().unwrap(); - let args = vec![s("check"), s("bogus")]; - let result = run_command(&args, temp.path()).await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Unknown check type"), - "expected 'Unknown check type', got: {msg}" - ); - } -} diff --git a/crates/wflpkg/src/manifest/mod.rs b/crates/wflpkg/src/manifest/mod.rs deleted file mode 100644 index 3346c64a..00000000 --- a/crates/wflpkg/src/manifest/mod.rs +++ /dev/null @@ -1,74 +0,0 @@ -pub mod parser; -pub mod version; -pub mod writer; - -use version::VersionConstraint; - -/// The project manifest parsed from `project.wfl`. -#[derive(Debug, Clone, Default)] -pub struct ProjectManifest { - pub name: String, - pub version_string: String, - pub description: String, - pub authors: Vec, - pub license: Option, - pub entry: Option, - pub repository: Option, - pub registry: Option, - pub dependencies: Vec, - pub permissions: Vec, -} - -impl ProjectManifest { - /// Load a manifest from a file path. - pub fn load(path: &std::path::Path) -> Result { - let content = std::fs::read_to_string(path)?; - parser::parse_manifest(&content) - } - - /// Save the manifest to a file path. - pub fn save(&self, path: &std::path::Path) -> Result<(), crate::error::PackageError> { - let content = writer::write_manifest(self); - std::fs::write(path, content)?; - Ok(()) - } - - /// Get the entry point, defaulting to "src/main.wfl". - pub fn entry_point(&self) -> &str { - self.entry.as_deref().unwrap_or("src/main.wfl") - } - - /// Get the registry URL, defaulting to "wflhub.org". - pub fn registry_url(&self) -> &str { - self.registry.as_deref().unwrap_or("wflhub.org") - } - - /// Find a dependency by name. - pub fn find_dependency(&self, name: &str) -> Option<&Dependency> { - self.dependencies.iter().find(|d| d.name == name) - } - - /// Add or update a dependency. - pub fn add_dependency(&mut self, dep: Dependency) { - if let Some(existing) = self.dependencies.iter_mut().find(|d| d.name == dep.name) { - *existing = dep; - } else { - self.dependencies.push(dep); - } - } - - /// Remove a dependency by name. Returns true if it was found and removed. - pub fn remove_dependency(&mut self, name: &str) -> bool { - let len_before = self.dependencies.len(); - self.dependencies.retain(|d| d.name != name); - self.dependencies.len() < len_before - } -} - -/// A dependency declaration from the manifest. -#[derive(Debug, Clone)] -pub struct Dependency { - pub name: String, - pub constraint: VersionConstraint, - pub dev_only: bool, -} diff --git a/crates/wflpkg/src/manifest/parser.rs b/crates/wflpkg/src/manifest/parser.rs deleted file mode 100644 index ffa3056c..00000000 --- a/crates/wflpkg/src/manifest/parser.rs +++ /dev/null @@ -1,272 +0,0 @@ -use crate::error::PackageError; -use crate::manifest::version::VersionConstraint; -use crate::manifest::{Dependency, ProjectManifest}; - -/// Parse a `project.wfl` manifest from its text content. -pub fn parse_manifest(content: &str) -> Result { - let mut manifest = ProjectManifest::default(); - let mut line_num = 0; - - for raw_line in content.lines() { - line_num += 1; - let line = raw_line.trim(); - - // Skip empty lines and comments - if line.is_empty() || line.starts_with("//") { - continue; - } - - // Try to parse "requires ..." dependency lines - if let Some(rest) = line.strip_prefix("requires ") { - let dep = parse_dependency(rest, line_num)?; - manifest.dependencies.push(dep); - continue; - } - - // Try to parse "needs ..." permission lines - if let Some(rest) = line.strip_prefix("needs ") { - let perm = rest.trim().to_string(); - manifest.permissions.push(perm); - continue; - } - - // Try to parse "authors are ..." (multi-author) - if let Some(authors_str) = line.strip_prefix("authors are ") { - let authors: Vec = authors_str - .split(" and ") - .flat_map(|part| part.split(',')) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - manifest.authors = authors; - continue; - } - - // Parse "key is value" lines - if let Some(pos) = line.find(" is ") { - let key = line[..pos].trim(); - let value = line[pos + 4..].trim(); - - match key { - "name" => manifest.name = value.to_string(), - "version" => manifest.version_string = value.to_string(), - "description" => manifest.description = value.to_string(), - "author" => manifest.authors = vec![value.to_string()], - "license" => manifest.license = Some(value.to_string()), - "entry" => manifest.entry = Some(value.to_string()), - "repository" => manifest.repository = Some(value.to_string()), - "registry" => manifest.registry = Some(value.to_string()), - _ => { - return Err(PackageError::ManifestParseError { - line: line_num, - message: format!( - "I do not recognize the field \"{}\".\n\ - Valid fields are: name, version, description, author, authors, \ - license, entry, repository, registry, requires, needs", - key - ), - }); - } - } - } else { - return Err(PackageError::ManifestParseError { - line: line_num, - message: format!( - "I could not understand this line:\n {}\n\n\ - Each line in project.wfl should use the format:\n\ - \x20 field is value\n\ - \x20 requires package-name version-constraint\n\ - \x20 needs permission-name", - line - ), - }); - } - } - - // Validate required fields - if manifest.name.is_empty() { - return Err(PackageError::ManifestParseError { - line: 0, - message: "The project.wfl file is missing a required field: name\n\ - Add a line like: name is my-project" - .to_string(), - }); - } - - if manifest.version_string.is_empty() { - return Err(PackageError::ManifestParseError { - line: 0, - message: "The project.wfl file is missing a required field: version\n\ - Add a line like: version is 26.1.1" - .to_string(), - }); - } - - if manifest.description.is_empty() { - return Err(PackageError::ManifestParseError { - line: 0, - message: "The project.wfl file is missing a required field: description\n\ - Add a line like: description is A brief description of your project" - .to_string(), - }); - } - - // Validate package name - validate_package_name(&manifest.name)?; - - Ok(manifest) -} - -/// Parse a dependency line after the "requires " prefix. -/// Examples: -/// "http-client 26.1 or newer" -/// "test-runner 26.1 or newer for development" -/// "text-utils any version" -fn parse_dependency(s: &str, line_num: usize) -> Result { - let s = s.trim(); - - // Check for "for development" suffix - let (rest, dev_only) = if let Some(stripped) = s.strip_suffix(" for development") { - (stripped, true) - } else { - (s, false) - }; - - // Split into package name and version constraint - // The name is the first word, everything after is the constraint - let first_space = rest - .find(' ') - .ok_or_else(|| PackageError::ManifestParseError { - line: line_num, - message: format!( - "I expected a version constraint after the package name in:\n requires {}\n\n\ - For example:\n\ - \x20 requires {} any version\n\ - \x20 requires {} 26.1 or newer", - s, rest, rest - ), - })?; - - let name = rest[..first_space].trim().to_string(); - let constraint_str = rest[first_space + 1..].trim(); - - validate_package_name(&name)?; - let constraint = VersionConstraint::parse(constraint_str)?; - - Ok(Dependency { - name, - constraint, - dev_only, - }) -} - -/// Validate a package name. -pub(crate) fn validate_package_name(name: &str) -> Result<(), PackageError> { - if name.is_empty() || name.len() > 64 { - return Err(PackageError::InvalidPackageName(name.to_string())); - } - - let first = name.chars().next().unwrap(); - if !first.is_ascii_lowercase() { - return Err(PackageError::InvalidPackageName(name.to_string())); - } - - for c in name.chars() { - if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' { - return Err(PackageError::InvalidPackageName(name.to_string())); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_minimal_manifest() { - let content = "\ -// project.wfl -name is my-app -version is 26.1.1 -description is A test application -"; - let manifest = parse_manifest(content).unwrap(); - assert_eq!(manifest.name, "my-app"); - assert_eq!(manifest.version_string, "26.1.1"); - assert_eq!(manifest.description, "A test application"); - } - - #[test] - fn test_parse_full_manifest() { - let content = "\ -// project.wfl - Package manifest - -name is greeting -version is 26.2.1 -description is A web application that greets visitors -author is Alice Smith -license is MIT - -entry is src/main.wfl - -requires http-client 26.1 or newer -requires json-parser 25.12 or newer -requires text-utils any version - -requires test-runner 26.1 or newer for development -"; - let manifest = parse_manifest(content).unwrap(); - assert_eq!(manifest.name, "greeting"); - assert_eq!(manifest.authors, vec!["Alice Smith"]); - assert_eq!(manifest.license, Some("MIT".to_string())); - assert_eq!(manifest.entry, Some("src/main.wfl".to_string())); - assert_eq!(manifest.dependencies.len(), 4); - assert!(!manifest.dependencies[0].dev_only); - assert!(manifest.dependencies[3].dev_only); - assert_eq!(manifest.dependencies[3].name, "test-runner"); - } - - #[test] - fn test_parse_multiple_authors() { - let content = "\ -name is my-app -version is 26.1.1 -description is Test -authors are Alice Smith and Bob Jones -"; - let manifest = parse_manifest(content).unwrap(); - assert_eq!(manifest.authors, vec!["Alice Smith", "Bob Jones"]); - } - - #[test] - fn test_parse_permissions() { - let content = "\ -name is my-app -version is 26.1.1 -description is Test -needs file-access -needs network-access -"; - let manifest = parse_manifest(content).unwrap(); - assert_eq!(manifest.permissions, vec!["file-access", "network-access"]); - } - - #[test] - fn test_missing_name_error() { - let content = "version is 26.1.1\ndescription is Test"; - let err = parse_manifest(content).unwrap_err(); - assert!(err.to_string().contains("missing a required field: name")); - } - - #[test] - fn test_invalid_package_name() { - assert!(validate_package_name("my-app").is_ok()); - assert!(validate_package_name("http-client").is_ok()); - assert!(validate_package_name("a123").is_ok()); - assert!(validate_package_name("MyApp").is_err()); - assert!(validate_package_name("123abc").is_err()); - assert!(validate_package_name("").is_err()); - } -} diff --git a/crates/wflpkg/src/manifest/version.rs b/crates/wflpkg/src/manifest/version.rs deleted file mode 100644 index 1cf37a0a..00000000 --- a/crates/wflpkg/src/manifest/version.rs +++ /dev/null @@ -1,273 +0,0 @@ -use std::cmp::Ordering; -use std::fmt; - -use crate::error::PackageError; - -/// A WFL version following YY.MM.BUILD calendar versioning. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct Version { - pub year: u32, - pub month: u32, - pub build: Option, -} - -impl Version { - pub fn new(year: u32, month: u32, build: Option) -> Self { - Self { year, month, build } - } - - /// Parse a version string like "26.1.3", "26.1", or "27" (year only). - pub fn parse(s: &str) -> Result { - let parts: Vec<&str> = s.trim().split('.').collect(); - match parts.len() { - 1 => { - // Year only: "27" means the start of year 27 (27.1.0) - let year = parts[0] - .parse::() - .map_err(|_| PackageError::InvalidVersion(s.to_string()))?; - Ok(Self::new(year, 1, None)) - } - 2 => { - let year = parts[0] - .parse::() - .map_err(|_| PackageError::InvalidVersion(s.to_string()))?; - let month = parts[1] - .parse::() - .map_err(|_| PackageError::InvalidVersion(s.to_string()))?; - if !(1..=12).contains(&month) { - return Err(PackageError::InvalidVersion(s.to_string())); - } - Ok(Self::new(year, month, None)) - } - 3 => { - let year = parts[0] - .parse::() - .map_err(|_| PackageError::InvalidVersion(s.to_string()))?; - let month = parts[1] - .parse::() - .map_err(|_| PackageError::InvalidVersion(s.to_string()))?; - let build = parts[2] - .parse::() - .map_err(|_| PackageError::InvalidVersion(s.to_string()))?; - if !(1..=12).contains(&month) { - return Err(PackageError::InvalidVersion(s.to_string())); - } - Ok(Self::new(year, month, Some(build))) - } - _ => Err(PackageError::InvalidVersion(s.to_string())), - } - } - - /// Return the version with build defaulting to 0 for comparisons. - fn build_or_zero(&self) -> u32 { - self.build.unwrap_or(0) - } - - /// Check if this version matches a version with no build specified - /// (i.e. "26.1" matches any "26.1.x"). - pub fn matches_prefix(&self, prefix: &Version) -> bool { - self.year == prefix.year && self.month == prefix.month - } -} - -impl fmt::Display for Version { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.build { - Some(build) => write!(f, "{}.{}.{}", self.year, self.month, build), - None => write!(f, "{}.{}", self.year, self.month), - } - } -} - -impl Ord for Version { - fn cmp(&self, other: &Self) -> Ordering { - self.year - .cmp(&other.year) - .then(self.month.cmp(&other.month)) - .then(self.build_or_zero().cmp(&other.build_or_zero())) - } -} - -impl PartialOrd for Version { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -/// Version constraint types for dependency resolution. -#[derive(Debug, Clone, PartialEq)] -pub enum VersionConstraint { - /// `26.1 or newer` — >= 26.1.0 - OrNewer(Version), - /// `26.1.3 exactly` — == 26.1.3 - Exactly(Version), - /// `between 25.12 and 26.2` — >= 25.12.0, <= 26.2.x - Between(Version, Version), - /// `any version` — no constraint - AnyVersion, - /// `above 25.6` — > 25.6.x - Above(Version), - /// `below 27` — < 27.0.0 - Below(Version), - /// `26.1 or newer but below 27` — >= 26.1.0, < 27.0.0 - AboveBelow(Version, Version), -} - -impl VersionConstraint { - /// Check if a version satisfies this constraint. - pub fn matches(&self, version: &Version) -> bool { - match self { - VersionConstraint::OrNewer(min) => version >= min, - VersionConstraint::Exactly(exact) => { - if exact.build.is_some() { - version == exact - } else { - version.matches_prefix(exact) - } - } - VersionConstraint::Between(min, max) => version >= min && version <= max, - VersionConstraint::AnyVersion => true, - VersionConstraint::Above(min) => version > min, - VersionConstraint::Below(max) => version < max, - VersionConstraint::AboveBelow(min, max) => version >= min && version < max, - } - } - - /// Parse a version constraint from a string like "26.1 or newer". - pub fn parse(s: &str) -> Result { - let s = s.trim(); - - if s == "any version" { - return Ok(VersionConstraint::AnyVersion); - } - - // "between X and Y" - if let Some(rest) = s.strip_prefix("between ") { - let parts: Vec<&str> = rest.splitn(2, " and ").collect(); - if parts.len() != 2 { - return Err(PackageError::InvalidVersionConstraint(s.to_string())); - } - let min = Version::parse(parts[0])?; - let max = Version::parse(parts[1])?; - return Ok(VersionConstraint::Between(min, max)); - } - - // "above X" - if let Some(rest) = s.strip_prefix("above ") { - let version = Version::parse(rest)?; - return Ok(VersionConstraint::Above(version)); - } - - // "below X" - if let Some(rest) = s.strip_prefix("below ") { - let version = Version::parse(rest)?; - return Ok(VersionConstraint::Below(version)); - } - - // "X or newer but below Y" - if s.contains(" or newer but below ") { - let parts: Vec<&str> = s.splitn(2, " or newer but below ").collect(); - if parts.len() != 2 { - return Err(PackageError::InvalidVersionConstraint(s.to_string())); - } - let min = Version::parse(parts[0])?; - let max = Version::parse(parts[1])?; - return Ok(VersionConstraint::AboveBelow(min, max)); - } - - // "X or newer" - if let Some(version_str) = s.strip_suffix(" or newer") { - let version = Version::parse(version_str)?; - return Ok(VersionConstraint::OrNewer(version)); - } - - // "X exactly" - if let Some(version_str) = s.strip_suffix(" exactly") { - let version = Version::parse(version_str)?; - return Ok(VersionConstraint::Exactly(version)); - } - - Err(PackageError::InvalidVersionConstraint(s.to_string())) - } -} - -impl fmt::Display for VersionConstraint { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - VersionConstraint::OrNewer(v) => write!(f, "{} or newer", v), - VersionConstraint::Exactly(v) => write!(f, "{} exactly", v), - VersionConstraint::Between(min, max) => write!(f, "between {} and {}", min, max), - VersionConstraint::AnyVersion => write!(f, "any version"), - VersionConstraint::Above(v) => write!(f, "above {}", v), - VersionConstraint::Below(v) => write!(f, "below {}", v), - VersionConstraint::AboveBelow(min, max) => { - write!(f, "{} or newer but below {}", min, max) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_version_parse() { - let v = Version::parse("26.1.3").unwrap(); - assert_eq!(v.year, 26); - assert_eq!(v.month, 1); - assert_eq!(v.build, Some(3)); - - let v = Version::parse("26.1").unwrap(); - assert_eq!(v.year, 26); - assert_eq!(v.month, 1); - assert_eq!(v.build, None); - } - - #[test] - fn test_version_ordering() { - let v1 = Version::parse("25.12.1").unwrap(); - let v2 = Version::parse("26.1.0").unwrap(); - let v3 = Version::parse("26.1.3").unwrap(); - assert!(v1 < v2); - assert!(v2 < v3); - } - - #[test] - fn test_version_display() { - assert_eq!(Version::new(26, 1, Some(3)).to_string(), "26.1.3"); - assert_eq!(Version::new(26, 1, None).to_string(), "26.1"); - } - - #[test] - fn test_constraint_parse_and_match() { - let c = VersionConstraint::parse("26.1 or newer").unwrap(); - assert!(c.matches(&Version::parse("26.1.0").unwrap())); - assert!(c.matches(&Version::parse("26.2.0").unwrap())); - assert!(!c.matches(&Version::parse("25.12.0").unwrap())); - - let c = VersionConstraint::parse("26.1.3 exactly").unwrap(); - assert!(c.matches(&Version::parse("26.1.3").unwrap())); - assert!(!c.matches(&Version::parse("26.1.4").unwrap())); - - let c = VersionConstraint::parse("any version").unwrap(); - assert!(c.matches(&Version::parse("1.1.0").unwrap())); - - let c = VersionConstraint::parse("between 25.12 and 26.2").unwrap(); - assert!(c.matches(&Version::parse("26.1.0").unwrap())); - assert!(!c.matches(&Version::parse("26.3.0").unwrap())); - - let c = VersionConstraint::parse("above 25.6").unwrap(); - assert!(c.matches(&Version::parse("25.7.0").unwrap())); - assert!(!c.matches(&Version::parse("25.6.0").unwrap())); - - let c = VersionConstraint::parse("below 27").unwrap(); - assert!(c.matches(&Version::parse("26.12.99").unwrap())); - assert!(!c.matches(&Version::parse("27.1.0").unwrap())); - - let c = VersionConstraint::parse("26.1 or newer but below 27").unwrap(); - assert!(c.matches(&Version::parse("26.5.0").unwrap())); - assert!(!c.matches(&Version::parse("25.12.0").unwrap())); - assert!(!c.matches(&Version::parse("27.1.0").unwrap())); - } -} diff --git a/crates/wflpkg/src/manifest/writer.rs b/crates/wflpkg/src/manifest/writer.rs deleted file mode 100644 index 721481ac..00000000 --- a/crates/wflpkg/src/manifest/writer.rs +++ /dev/null @@ -1,138 +0,0 @@ -use crate::manifest::ProjectManifest; - -/// Serialize a `ProjectManifest` back to the `project.wfl` format. -pub fn write_manifest(manifest: &ProjectManifest) -> String { - let mut lines = Vec::new(); - - lines.push("// project.wfl".to_string()); - lines.push(String::new()); - - lines.push(format!("name is {}", manifest.name)); - lines.push(format!("version is {}", manifest.version_string)); - lines.push(format!("description is {}", manifest.description)); - - if manifest.authors.len() == 1 { - lines.push(format!("author is {}", manifest.authors[0])); - } else if manifest.authors.len() > 1 { - lines.push(format!("authors are {}", manifest.authors.join(" and "))); - } - - if let Some(license) = &manifest.license { - lines.push(format!("license is {}", license)); - } - - if let Some(entry) = &manifest.entry { - lines.push(String::new()); - lines.push(format!("entry is {}", entry)); - } - - if let Some(repository) = &manifest.repository { - lines.push(format!("repository is {}", repository)); - } - - if let Some(registry) = &manifest.registry { - lines.push(format!("registry is {}", registry)); - } - - // Dependencies - let regular_deps: Vec<_> = manifest - .dependencies - .iter() - .filter(|d| !d.dev_only) - .collect(); - let dev_deps: Vec<_> = manifest - .dependencies - .iter() - .filter(|d| d.dev_only) - .collect(); - - if !regular_deps.is_empty() || !dev_deps.is_empty() { - lines.push(String::new()); - } - - for dep in ®ular_deps { - lines.push(format!("requires {} {}", dep.name, dep.constraint)); - } - - if !dev_deps.is_empty() && !regular_deps.is_empty() { - lines.push(String::new()); - } - - for dep in &dev_deps { - lines.push(format!( - "requires {} {} for development", - dep.name, dep.constraint - )); - } - - // Permissions - if !manifest.permissions.is_empty() { - lines.push(String::new()); - for perm in &manifest.permissions { - lines.push(format!("needs {}", perm)); - } - } - - lines.push(String::new()); - lines.join("\n") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::Dependency; - use crate::manifest::version::{Version, VersionConstraint}; - - #[test] - fn test_write_minimal_manifest() { - let manifest = ProjectManifest { - name: "my-app".to_string(), - version_string: "26.1.1".to_string(), - description: "A test app".to_string(), - ..Default::default() - }; - let output = write_manifest(&manifest); - assert!(output.contains("name is my-app")); - assert!(output.contains("version is 26.1.1")); - assert!(output.contains("description is A test app")); - } - - #[test] - fn test_roundtrip() { - let manifest = ProjectManifest { - name: "greeting".to_string(), - version_string: "26.2.1".to_string(), - description: "A web application".to_string(), - authors: vec!["Alice Smith".to_string()], - license: Some("MIT".to_string()), - entry: Some("src/main.wfl".to_string()), - dependencies: vec![ - Dependency { - name: "http-client".to_string(), - constraint: VersionConstraint::OrNewer(Version::new(26, 1, None)), - dev_only: false, - }, - Dependency { - name: "test-runner".to_string(), - constraint: VersionConstraint::OrNewer(Version::new(26, 1, None)), - dev_only: true, - }, - ], - permissions: vec!["network-access".to_string()], - ..Default::default() - }; - - let output = write_manifest(&manifest); - - // Re-parse - let parsed = crate::manifest::parser::parse_manifest(&output).unwrap(); - assert_eq!(parsed.name, manifest.name); - assert_eq!(parsed.version_string, manifest.version_string); - assert_eq!(parsed.description, manifest.description); - assert_eq!(parsed.authors, manifest.authors); - assert_eq!(parsed.license, manifest.license); - assert_eq!(parsed.entry, manifest.entry); - assert_eq!(parsed.dependencies.len(), 2); - assert_eq!(parsed.permissions, manifest.permissions); - } -} diff --git a/crates/wflpkg/src/package_files.rs b/crates/wflpkg/src/package_files.rs deleted file mode 100644 index 5868ccd2..00000000 --- a/crates/wflpkg/src/package_files.rs +++ /dev/null @@ -1,301 +0,0 @@ -use std::path::{Path, PathBuf}; - -use ignore::Match; -use ignore::gitignore::{Gitignore, GitignoreBuilder}; - -use crate::error::PackageError; - -const MAX_IGNORE_FILE_BYTES: u64 = 1024 * 1024; -const MAX_TOTAL_IGNORE_BYTES: u64 = 4 * 1024 * 1024; -const MAX_TOTAL_IGNORE_RULES: usize = 4096; -const MAX_IGNORE_FILES: usize = 1024; - -/// Git-ignore matchers active for the directory currently being traversed. -/// -/// Each `.gitignore` is parsed by the same mature matcher used by ripgrep's -/// ignore walker. Nested matchers are evaluated after their parents so they -/// have Git's expected precedence. Callers must pop the number returned by -/// `push_for_dir` when they leave a directory. -pub(crate) struct IgnoreStack { - files: Vec, - total_bytes: u64, - total_rules: usize, - ignore_files: usize, -} - -struct IgnoreFile { - base: PathBuf, - matcher: Gitignore, -} - -impl IgnoreStack { - pub(crate) fn new(project_dir: &Path) -> Result { - let mut stack = Self { - files: Vec::new(), - total_bytes: 0, - total_rules: 0, - ignore_files: 0, - }; - stack.push_for_dir(project_dir, Path::new(""))?; - Ok(stack) - } - - /// Load the `.gitignore` in `dir`, scoped to `relative_dir`. - pub(crate) fn push_for_dir( - &mut self, - dir: &Path, - relative_dir: &Path, - ) -> Result { - let ignore_path = dir.join(".gitignore"); - match std::fs::symlink_metadata(&ignore_path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(PackageError::General(format!( - ".gitignore must be a regular, non-symlink file: {}", - ignore_path.display() - ))); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(error) => return Err(PackageError::Io(error)), - } - - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let file = match options.open(&ignore_path) { - Ok(file) => file, - Err(error) => return Err(PackageError::Io(error)), - }; - - let metadata = file.metadata()?; - if !metadata.is_file() { - return Ok(0); - } - if metadata.len() > MAX_IGNORE_FILE_BYTES { - return Err(PackageError::General(format!( - ".gitignore is too large to process safely: {}", - ignore_path.display() - ))); - } - - use std::io::Read; - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take(MAX_IGNORE_FILE_BYTES + 1) - .read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_IGNORE_FILE_BYTES { - return Err(PackageError::General(format!( - ".gitignore grew too large while it was being read: {}", - ignore_path.display() - ))); - } - let content = String::from_utf8(bytes).map_err(|_| { - PackageError::General(format!( - ".gitignore is not valid UTF-8: {}", - ignore_path.display() - )) - })?; - - let mut builder = GitignoreBuilder::new(""); - builder - .case_insensitive(cfg!(windows)) - .map_err(ignore_pattern_error)?; - for line in content.lines() { - reject_unsupported_git_classes(line)?; - builder - .add_line(Some(ignore_path.clone()), line) - .map_err(ignore_pattern_error)?; - } - let matcher = builder.build().map_err(ignore_pattern_error)?; - let rule_count = (matcher.num_ignores() + matcher.num_whitelists()) as usize; - - let next_bytes = self - .total_bytes - .checked_add(content.len() as u64) - .ok_or_else(ignore_budget_error)?; - let next_files = self - .ignore_files - .checked_add(1) - .ok_or_else(ignore_budget_error)?; - let next_rules = self - .total_rules - .checked_add(rule_count) - .ok_or_else(ignore_budget_error)?; - if next_bytes > MAX_TOTAL_IGNORE_BYTES - || next_files > MAX_IGNORE_FILES - || next_rules > MAX_TOTAL_IGNORE_RULES - { - return Err(ignore_budget_error()); - } - - self.total_bytes = next_bytes; - self.ignore_files = next_files; - self.total_rules = next_rules; - if rule_count == 0 { - return Ok(0); - } - self.files.push(IgnoreFile { - base: relative_dir.to_path_buf(), - matcher, - }); - Ok(1) - } - - pub(crate) fn pop(&mut self, count: usize) { - self.files.truncate(self.files.len().saturating_sub(count)); - } - - pub(crate) fn is_ignored( - &self, - relative_path: &Path, - is_dir: bool, - ) -> Result { - // Fail closed instead of publishing a path that cannot be represented - // consistently in a portable package checksum. - if relative_path.to_str().is_none() { - return Err(PackageError::General(format!( - "Package path is not valid Unicode: {}", - relative_path.display() - ))); - } - - let mut ignored = false; - for file in &self.files { - let Ok(scoped_path) = relative_path.strip_prefix(&file.base) else { - continue; - }; - if scoped_path.as_os_str().is_empty() { - continue; - } - match file.matcher.matched(scoped_path, is_dir) { - Match::Ignore(_) => ignored = true, - Match::Whitelist(_) => ignored = false, - Match::None => {} - } - } - Ok(ignored) - } -} - -fn reject_unsupported_git_classes(line: &str) -> Result<(), PackageError> { - // Git wildmatch supports POSIX bracket classes, while the matcher used by - // `ignore` currently accepts but does not match them. Refuse these forms - // instead of silently publishing an intended secret. - if line.contains("[[:") || line.contains("[[.") || line.contains("[[=") { - return Err(PackageError::General( - "A .gitignore uses a POSIX bracket class that cannot be evaluated safely; refusing to publish." - .to_string(), - )); - } - Ok(()) -} - -fn ignore_pattern_error(error: ignore::Error) -> PackageError { - PackageError::General(format!( - "A .gitignore contains a pattern that cannot be evaluated safely; refusing to publish: {}", - error - )) -} - -fn ignore_budget_error() -> PackageError { - PackageError::General( - "The project's .gitignore rules exceed the safe processing limit; refusing to publish." - .to_string(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn root_rules_ignore_logs_and_allow_negation() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write(temp.path().join(".gitignore"), "*.log\n!important.log\n").unwrap(); - let stack = IgnoreStack::new(temp.path()).unwrap(); - - assert!(stack.is_ignored(Path::new("debug.log"), false).unwrap()); - assert!( - stack - .is_ignored(Path::new("nested/debug.log"), false) - .unwrap() - ); - assert!(!stack.is_ignored(Path::new("important.log"), false).unwrap()); - } - - #[test] - fn directory_rules_exclude_descendants_by_pruning() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write(temp.path().join(".gitignore"), "secrets/\n").unwrap(); - let stack = IgnoreStack::new(temp.path()).unwrap(); - - assert!(stack.is_ignored(Path::new("secrets"), true).unwrap()); - assert!(stack.is_ignored(Path::new("nested/secrets"), true).unwrap()); - } - - #[test] - fn negated_parent_does_not_clear_leaf_ignore() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write(temp.path().join(".gitignore"), "*.log\n!a/b\n").unwrap(); - let stack = IgnoreStack::new(temp.path()).unwrap(); - - assert!(stack.is_ignored(Path::new("a/b/x.log"), false).unwrap()); - } - - #[test] - fn anchored_rules_only_match_from_their_base() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write(temp.path().join(".gitignore"), "/.env\n").unwrap(); - let stack = IgnoreStack::new(temp.path()).unwrap(); - - assert!(stack.is_ignored(Path::new(".env"), false).unwrap()); - assert!(!stack.is_ignored(Path::new("nested/.env"), false).unwrap()); - } - - #[test] - fn escaped_metacharacters_and_star_runs_follow_git_syntax() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write( - temp.path().join(".gitignore"), - "secret\\?.txt\nliteral\\*.env\nname\\ \nfoo**bar\n", - ) - .unwrap(); - let stack = IgnoreStack::new(temp.path()).unwrap(); - - assert!(stack.is_ignored(Path::new("secret?.txt"), false).unwrap()); - assert!(!stack.is_ignored(Path::new("secret1.txt"), false).unwrap()); - assert!(stack.is_ignored(Path::new("literal*.env"), false).unwrap()); - assert!( - !stack - .is_ignored(Path::new("literal-secret.env"), false) - .unwrap() - ); - assert!(stack.is_ignored(Path::new("name "), false).unwrap()); - assert!( - stack - .is_ignored(Path::new("foo-anything-bar"), false) - .unwrap() - ); - } - - #[test] - fn posix_character_class_fails_closed() { - let temp = tempfile::tempdir().unwrap(); - std::fs::write(temp.path().join(".gitignore"), "file[[:digit:]].env\n").unwrap(); - assert!(IgnoreStack::new(temp.path()).is_err()); - } - - #[cfg(unix)] - #[test] - fn symlinked_gitignore_fails_closed() { - use std::os::unix::fs::symlink; - - let temp = tempfile::tempdir().unwrap(); - std::fs::write(temp.path().join("rules"), ".env\n").unwrap(); - symlink("rules", temp.path().join(".gitignore")).unwrap(); - assert!(IgnoreStack::new(temp.path()).is_err()); - } -} diff --git a/crates/wflpkg/src/permissions.rs b/crates/wflpkg/src/permissions.rs deleted file mode 100644 index f99e3b5a..00000000 --- a/crates/wflpkg/src/permissions.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::error::PackageError; - -/// Permission types that packages can declare with `needs`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Permission { - FileAccess, - NetworkAccess, - SystemAccess, - Unknown(String), -} - -impl Permission { - /// Parse a permission string. - pub fn parse(s: &str) -> Self { - match s.trim() { - "file-access" => Permission::FileAccess, - "network-access" => Permission::NetworkAccess, - "system-access" => Permission::SystemAccess, - other => Permission::Unknown(other.to_string()), - } - } - - /// Get a human-readable description of the permission. - pub fn description(&self) -> &str { - match self { - Permission::FileAccess => "Can read and write files on disk", - Permission::NetworkAccess => "Can make HTTP requests", - Permission::SystemAccess => "Can execute system commands", - Permission::Unknown(_) => "Unknown permission", - } - } - - /// Get the permission identifier string. - pub fn name(&self) -> &str { - match self { - Permission::FileAccess => "file-access", - Permission::NetworkAccess => "network-access", - Permission::SystemAccess => "system-access", - Permission::Unknown(s) => s, - } - } -} - -/// Prompt the user to confirm permissions for a package. -/// Returns Ok(true) if confirmed, Ok(false) if denied. -pub fn confirm_permissions( - package_name: &str, - permissions: &[String], -) -> Result { - if permissions.is_empty() { - return Ok(true); - } - - let parsed: Vec = permissions.iter().map(|s| Permission::parse(s)).collect(); - - println!( - "The package \"{}\" needs the following permissions:", - package_name - ); - for perm in &parsed { - println!(" - {}: {}", perm.name(), perm.description()); - } - println!(); - - // Use rustyline for input - use rustyline::DefaultEditor; - let mut editor = - DefaultEditor::new().map_err(|e| PackageError::General(format!("Input error: {}", e)))?; - - let response = editor - .readline("Do you want to add this package? (yes/no): ") - .map_err(|e| PackageError::General(format!("Input error: {}", e)))?; - - Ok(response.trim().to_lowercase() == "yes" || response.trim().to_lowercase() == "y") -} diff --git a/crates/wflpkg/src/registry/advisory.rs b/crates/wflpkg/src/registry/advisory.rs deleted file mode 100644 index d8e9f0f4..00000000 --- a/crates/wflpkg/src/registry/advisory.rs +++ /dev/null @@ -1,87 +0,0 @@ -use crate::error::PackageError; -use crate::manifest::version::Version; - -/// A security advisory for a package. -#[derive(Debug, Clone)] -pub struct Advisory { - pub package: String, - pub severity: String, - pub description: String, - pub affected_versions: String, - pub fixed_in: Option, -} - -/// Query the registry's advisory database for known vulnerabilities. -pub async fn check_advisories( - registry_url: &str, - packages: &[(String, Version)], -) -> Result, PackageError> { - if packages.is_empty() { - return Ok(Vec::new()); - } - - let url = format!("{}/api/v1/advisories", registry_url.trim_end_matches('/')); - - let package_list: Vec = packages - .iter() - .map(|(name, version)| { - serde_json::json!({ - "name": name, - "version": version.to_string() - }) - }) - .collect(); - - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(30)) - .timeout(std::time::Duration::from_secs(300)) - .build() - .map_err(|e| PackageError::General(format!("HTTP client error: {}", e)))?; - let response = client - .post(&url) - .json(&serde_json::json!({ "packages": package_list })) - .send() - .await - .map_err(|e| PackageError::RegistryUnreachable(format!("{}: {}", registry_url, e)))?; - - if !response.status().is_success() { - return Err(PackageError::Http(format!( - "Advisory check returned status {}", - response.status() - ))); - } - - let body: serde_json::Value = response - .json() - .await - .map_err(|e| PackageError::Http(format!("Failed to parse advisory response: {}", e)))?; - - let advisories = body - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|v| { - Some(Advisory { - package: v["package"].as_str()?.to_string(), - severity: v["severity"].as_str().unwrap_or("UNKNOWN").to_string(), - description: v["description"].as_str().unwrap_or("").to_string(), - affected_versions: v["affected"].as_str().unwrap_or("").to_string(), - fixed_in: v["fixed_in"].as_str().and_then(|s| Version::parse(s).ok()), - }) - }) - .collect(); - - Ok(advisories) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_check_advisories_empty_packages_returns_empty() { - let result = check_advisories("https://registry.example.com", &[]).await; - assert!(result.is_ok()); - assert!(result.unwrap().is_empty()); - } -} diff --git a/crates/wflpkg/src/registry/api.rs b/crates/wflpkg/src/registry/api.rs deleted file mode 100644 index a2d04c4d..00000000 --- a/crates/wflpkg/src/registry/api.rs +++ /dev/null @@ -1,474 +0,0 @@ -use crate::error::PackageError; -use crate::manifest::version::Version; - -/// Default connect timeout for registry requests (30 seconds). -const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -/// Default request timeout for registry requests (5 minutes). -const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); -/// Maximum compressed package accepted for one publish request (100 MiB). -const MAX_PUBLISH_ARCHIVE_BYTES: u64 = 100 * 1024 * 1024; -/// Maximum registry response body retained by the client (1 MiB). -const MAX_REGISTRY_RESPONSE_BYTES: usize = 1024 * 1024; - -/// A client for communicating with the WFL package registry. -pub struct RegistryClient { - base_url: String, - auth_token: Option, - client: reqwest::Client, -} - -/// Package metadata returned from the registry. -#[derive(Debug, Clone)] -pub struct PackageInfo { - pub name: String, - pub description: String, - pub latest_version: Version, - pub versions: Vec, - pub author: String, - pub license: String, - pub downloads: u64, -} - -/// Search result from the registry. -#[derive(Debug, Clone)] -pub struct SearchResult { - pub name: String, - pub description: String, - pub version: Version, - pub downloads: u64, -} - -impl RegistryClient { - /// Create a new registry client for the given base URL. - pub fn new(base_url: &str) -> Result { - let client = reqwest::Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build() - .map_err(|e| PackageError::General(format!("HTTP client error: {}", e)))?; - Ok(Self { - base_url: base_url.trim_end_matches('/').to_string(), - auth_token: None, - client, - }) - } - - /// Set the authentication token. - pub fn set_auth_token(&mut self, token: String) { - self.auth_token = Some(token); - } - - /// Search for packages matching a query. - pub async fn search(&self, query: &str) -> Result, PackageError> { - let url = build_search_url(&self.base_url, query)?; - let response = - self.client.get(&url).send().await.map_err(|e| { - PackageError::RegistryUnreachable(format!("{}: {}", self.base_url, e)) - })?; - - if !response.status().is_success() { - return Err(PackageError::Http(format!( - "Registry returned status {}", - response.status() - ))); - } - - let bytes = read_response_bounded(response).await?; - let body: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|e| PackageError::Http(format!("Failed to parse response: {}", e)))?; - - let results = body - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|v| { - Some(SearchResult { - name: v["name"].as_str()?.to_string(), - description: v["description"].as_str().unwrap_or("").to_string(), - version: Version::parse(v["version"].as_str()?).ok()?, - downloads: v["downloads"].as_u64().unwrap_or(0), - }) - }) - .collect(); - - Ok(results) - } - - /// Get detailed information about a package. - pub async fn get_package_info(&self, name: &str) -> Result { - let url = build_package_url(&self.base_url, name)?; - let response = - self.client.get(&url).send().await.map_err(|e| { - PackageError::RegistryUnreachable(format!("{}: {}", self.base_url, e)) - })?; - - if response.status().as_u16() == 404 { - return Err(PackageError::PackageNotFound { - name: name.to_string(), - suggestions: Vec::new(), - }); - } - - if !response.status().is_success() { - return Err(PackageError::Http(format!( - "Registry returned status {}", - response.status() - ))); - } - - let bytes = read_response_bounded(response).await?; - let body: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|e| PackageError::Http(format!("Failed to parse response: {}", e)))?; - - let versions: Vec = body["versions"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|v| Version::parse(v.as_str()?).ok()) - .collect(); - - let latest = versions - .iter() - .max() - .cloned() - .unwrap_or(Version::new(0, 1, Some(0))); - - Ok(PackageInfo { - name: body["name"].as_str().unwrap_or(name).to_string(), - description: body["description"].as_str().unwrap_or("").to_string(), - latest_version: latest, - versions, - author: body["author"].as_str().unwrap_or("").to_string(), - license: body["license"].as_str().unwrap_or("").to_string(), - downloads: body["downloads"].as_u64().unwrap_or(0), - }) - } - - /// Get available versions for a package. - pub async fn get_versions(&self, name: &str) -> Result, PackageError> { - let info = self.get_package_info(name).await?; - Ok(info.versions) - } - - /// Upload a package archive to the registry. - pub async fn publish( - &self, - name: &str, - version: &Version, - archive_path: &std::path::Path, - checksum: &str, - ) -> Result<(), PackageError> { - let token = self - .auth_token - .as_ref() - .ok_or(PackageError::NotAuthenticated)?; - - let url = format!("{}/api/v1/packages", self.base_url); - let (archive_file, archive_len) = open_publish_archive(archive_path).await?; - - let form = reqwest::multipart::Form::new() - .text("name", name.to_string()) - .text("version", version.to_string()) - .text("checksum", checksum.to_string()) - .part( - "archive", - reqwest::multipart::Part::stream_with_length(archive_file, archive_len) - .file_name(format!("{}-{}.wflpkg", name, version)), - ); - - let response = self - .client - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .multipart(form) - .send() - .await - .map_err(|e| PackageError::RegistryUnreachable(format!("{}: {}", self.base_url, e)))?; - - if !response.status().is_success() { - let body = read_response_bounded(response).await?; - let body = String::from_utf8_lossy(&body); - return Err(PackageError::Http(format!("Failed to publish: {}", body))); - } - - Ok(()) - } - - /// Get the base URL. - pub fn base_url(&self) -> &str { - &self.base_url - } -} - -async fn open_publish_archive( - archive_path: &std::path::Path, -) -> Result<(tokio::fs::File, u64), PackageError> { - let metadata = tokio::fs::symlink_metadata(archive_path) - .await - .map_err(PackageError::Io)?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(PackageError::General( - "The upload archive is not a regular file.".to_string(), - )); - } - if metadata.len() > MAX_PUBLISH_ARCHIVE_BYTES { - return Err(PackageError::General(format!( - "The compressed package exceeds the {} MiB publish limit.", - MAX_PUBLISH_ARCHIVE_BYTES / (1024 * 1024) - ))); - } - let file = tokio::fs::File::open(archive_path) - .await - .map_err(PackageError::Io)?; - let opened_metadata = file.metadata().await.map_err(PackageError::Io)?; - if !opened_metadata.is_file() || opened_metadata.len() != metadata.len() { - return Err(PackageError::General( - "The upload archive changed while it was being opened.".to_string(), - )); - } - Ok((file, opened_metadata.len())) -} - -async fn read_response_bounded(mut response: reqwest::Response) -> Result, PackageError> { - if response - .content_length() - .is_some_and(|length| length > MAX_REGISTRY_RESPONSE_BYTES as u64) - { - return Err(response_too_large_error()); - } - - let mut body = Vec::with_capacity( - response - .content_length() - .unwrap_or(0) - .min(MAX_REGISTRY_RESPONSE_BYTES as u64) as usize, - ); - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| PackageError::Http(format!("Failed to read response: {}", error)))? - { - append_response_chunk(&mut body, &chunk)?; - } - Ok(body) -} - -fn append_response_chunk(body: &mut Vec, chunk: &[u8]) -> Result<(), PackageError> { - let next_len = body - .len() - .checked_add(chunk.len()) - .ok_or_else(response_too_large_error)?; - if next_len > MAX_REGISTRY_RESPONSE_BYTES { - return Err(response_too_large_error()); - } - body.extend_from_slice(chunk); - Ok(()) -} - -fn response_too_large_error() -> PackageError { - PackageError::Http("Registry response exceeded the 1 MiB safety limit.".to_string()) -} - -/// Percent-encode a string for use as a URL path segment (RFC 3986). -/// Unreserved characters (A-Z, a-z, 0-9, `-`, `.`, `_`, `~`) pass through unchanged. -fn percent_encode_path_segment(s: &str) -> String { - let mut encoded = String::with_capacity(s.len()); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { - encoded.push(byte as char); - } - _ => { - encoded.push_str(&format!("%{:02X}", byte)); - } - } - } - encoded -} - -/// Build a properly-encoded search URL with the query as a `q` parameter. -fn build_search_url(base_url: &str, query: &str) -> Result { - let base = format!("{}/api/v1/search", base_url); - let mut url = reqwest::Url::parse(&base) - .map_err(|e| PackageError::Http(format!("Invalid base URL: {}", e)))?; - url.query_pairs_mut().append_pair("q", query); - Ok(url.to_string()) -} - -/// Build a properly-encoded package URL with the name as a path segment. -fn build_package_url(base_url: &str, name: &str) -> Result { - let encoded_name = percent_encode_path_segment(name); - let base = format!("{}/api/v1/packages/{}", base_url, encoded_name); - // Validate the URL is well-formed - reqwest::Url::parse(&base).map_err(|e| PackageError::Http(format!("Invalid URL: {}", e)))?; - Ok(base) -} - -#[cfg(test)] -mod tests { - use super::*; - - const BASE: &str = "https://registry.example.com"; - - #[tokio::test] - async fn oversized_publish_archive_is_rejected_before_upload() { - let temp = tempfile::tempdir().unwrap(); - let archive = temp.path().join("oversized.wflpkg"); - let file = std::fs::File::create(&archive).unwrap(); - file.set_len(MAX_PUBLISH_ARCHIVE_BYTES + 1).unwrap(); - assert!(open_publish_archive(&archive).await.is_err()); - } - - #[test] - fn oversized_registry_response_is_rejected() { - let mut body = vec![0; MAX_REGISTRY_RESPONSE_BYTES]; - assert!(append_response_chunk(&mut body, &[1]).is_err()); - assert_eq!(body.len(), MAX_REGISTRY_RESPONSE_BYTES); - } - - // --- search URL tests --- - - #[test] - fn test_search_url_simple_query() { - let url = build_search_url(BASE, "my-package").unwrap(); - assert_eq!( - url, - "https://registry.example.com/api/v1/search?q=my-package" - ); - } - - #[test] - fn test_search_url_encodes_spaces() { - let url = build_search_url(BASE, "hello world").unwrap(); - assert!(url.contains("q=hello")); - // Must not contain a raw space - assert!(!url.contains("q=hello world")); - } - - #[test] - fn test_search_url_encodes_ampersand() { - let url = build_search_url(BASE, "foo&bar=evil").unwrap(); - // The ampersand must be encoded — only one `q=` param should exist - assert!(url.contains("q=foo")); - assert!(!url.contains("&bar=evil")); - } - - #[test] - fn test_search_url_encodes_hash() { - let url = build_search_url(BASE, "foo#fragment").unwrap(); - // Hash must not truncate the URL; query must contain the full value - assert!(url.contains("q=foo")); - assert!(!url.ends_with("#fragment")); - } - - #[test] - fn test_search_url_encodes_question_mark() { - let url = build_search_url(BASE, "foo?extra").unwrap(); - // Only one `?` should appear (the query delimiter) - let question_marks = url.matches('?').count(); - assert_eq!(question_marks, 1); - } - - #[test] - fn test_search_url_empty_query() { - let url = build_search_url(BASE, "").unwrap(); - assert_eq!(url, "https://registry.example.com/api/v1/search?q="); - } - - // --- package URL tests --- - - #[test] - fn test_package_url_simple_name() { - let url = build_package_url(BASE, "my-package").unwrap(); - assert_eq!( - url, - "https://registry.example.com/api/v1/packages/my-package" - ); - } - - #[test] - fn test_package_url_encodes_slash() { - let url = build_package_url(BASE, "foo/bar").unwrap(); - assert!(url.contains("foo%2Fbar")); - // Must not create a new path segment - assert!(!url.contains("packages/foo/bar")); - } - - #[test] - fn test_package_url_encodes_dot_dot() { - let url = build_package_url(BASE, "../secret").unwrap(); - assert!(url.contains("..%2Fsecret")); - } - - #[test] - fn test_package_url_encodes_hash() { - let url = build_package_url(BASE, "pkg#frag").unwrap(); - assert!(url.contains("pkg%23frag")); - assert!(!url.contains('#')); - } - - #[test] - fn test_package_url_encodes_question_mark() { - let url = build_package_url(BASE, "pkg?q=evil").unwrap(); - assert!(url.contains("pkg%3Fq%3Devil")); - assert!(!url.contains('?')); - } - - // --- percent_encode_path_segment tests --- - - #[test] - fn test_encode_unreserved_chars_unchanged() { - let input = "ABCxyz019-._~"; - assert_eq!(percent_encode_path_segment(input), input); - } - - #[test] - fn test_encode_special_chars() { - let encoded = percent_encode_path_segment("a/b?c#d&e f"); - assert_eq!(encoded, "a%2Fb%3Fc%23d%26e%20f"); - } - - // --- RegistryClient construction tests --- - - #[test] - fn test_registry_client_strips_trailing_slash() { - let client = RegistryClient::new("https://example.com/").unwrap(); - assert_eq!(client.base_url(), "https://example.com"); - } - - #[test] - fn test_registry_client_preserves_clean_url() { - let client = RegistryClient::new("https://example.com").unwrap(); - assert_eq!(client.base_url(), "https://example.com"); - } - - #[test] - fn test_set_auth_token_does_not_panic() { - let mut client = RegistryClient::new(BASE).unwrap(); - client.set_auth_token("secret-token".to_string()); - // Should still work after setting token - assert_eq!(client.base_url(), BASE); - } - - #[tokio::test] - async fn test_publish_without_auth_returns_not_authenticated() { - let client = RegistryClient::new(BASE).unwrap(); - let version = crate::manifest::version::Version::new(26, 1, Some(0)); - let fake_path = std::path::Path::new("/nonexistent/archive.wflpkg"); - let result = client - .publish("test-pkg", &version, fake_path, "abc123") - .await; - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not logged in"), - "expected NotAuthenticated, got: {msg}" - ); - } - - #[test] - fn test_build_search_url_invalid_base() { - let result = build_search_url("not a url", "q"); - assert!(result.is_err(), "invalid base URL should fail"); - } -} diff --git a/crates/wflpkg/src/registry/auth.rs b/crates/wflpkg/src/registry/auth.rs deleted file mode 100644 index e50132f7..00000000 --- a/crates/wflpkg/src/registry/auth.rs +++ /dev/null @@ -1,479 +0,0 @@ -use std::path::PathBuf; - -use crate::error::PackageError; - -const MAX_AUTH_FILE_BYTES: u64 = 64 * 1024; -const MAX_TOKEN_BYTES: usize = 16 * 1024; - -/// Manages authentication tokens for registry access. -pub struct AuthManager { - auth_file: PathBuf, - // Only consulted when tightening directory permissions, which is a - // Unix-only concern. On non-Unix targets the field is intentionally unread. - #[cfg_attr(not(unix), allow(dead_code))] - manage_parent_permissions: bool, -} - -/// Stored authentication data. -#[derive(serde::Serialize, serde::Deserialize, Default)] -struct AuthData { - token: Option, - registry: Option, -} - -/// Authentication data bound to one canonical HTTPS registry origin. -pub struct RegistryCredentials { - token: String, - registry_origin: String, -} - -impl RegistryCredentials { - pub fn token(&self) -> &str { - &self.token - } - - pub fn registry_origin(&self) -> &str { - &self.registry_origin - } -} - -impl AuthManager { - /// Create a new auth manager using the default auth file location. - pub fn new() -> Result { - let home = get_home_dir()?; - let auth_file = home.join(".wfl").join("auth.json"); - Ok(Self { - auth_file, - manage_parent_permissions: true, - }) - } - - /// Create an auth manager with a custom path (for testing). - pub fn with_path(path: PathBuf) -> Self { - Self { - auth_file: path, - manage_parent_permissions: false, - } - } - - /// Get the stored authentication token. - pub fn get_token(&self) -> Result, PackageError> { - Ok(self.get_credentials()?.map(|credentials| credentials.token)) - } - - /// Get the stored token together with its canonical registry origin. - pub fn get_credentials(&self) -> Result, PackageError> { - match std::fs::symlink_metadata(&self.auth_file) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err(PackageError::General( - "The credentials path is not a regular file. Run `wfl logout`, then `wfl login` again." - .to_string(), - )); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(PackageError::Io(error)), - } - let content = read_auth_file_no_follow(&self.auth_file)?; - let data: AuthData = serde_json::from_str(&content) - .map_err(|e| PackageError::General(format!("Failed to parse auth file: {}", e)))?; - match (data.token, data.registry) { - (Some(token), Some(registry)) if !token.trim().is_empty() => { - let registry_origin = normalize_registry_origin(®istry).map_err(|_| { - PackageError::General( - "Stored credentials have an invalid registry scope. Run `wfl logout`, then `wfl login` again." - .to_string(), - ) - })?; - Ok(Some(RegistryCredentials { - token, - registry_origin, - })) - } - (None, None) => Ok(None), - _ => Err(PackageError::General( - "Stored credentials are incomplete. Run `wfl logout`, then `wfl login` again." - .to_string(), - )), - } - } - - /// Store an authentication token. - pub fn store_token(&self, token: &str, registry: &str) -> Result<(), PackageError> { - use zeroize::Zeroize; - - if token.trim().is_empty() { - return Err(PackageError::General( - "Cannot store an empty authentication token.".to_string(), - )); - } - if token.len() > MAX_TOKEN_BYTES { - return Err(PackageError::General( - "Authentication token exceeds the 16 KiB safety limit.".to_string(), - )); - } - let registry_origin = normalize_registry_origin(registry)?; - - { - let parent = self - .auth_file - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")); - // Whether we just created the directory. Only read on Unix, where a - // freshly created credentials directory is locked down to 0o700. - #[cfg_attr(not(unix), allow(unused_variables))] - let created_parent = match std::fs::symlink_metadata(parent) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err(PackageError::General( - "The credentials directory must be a real directory, not a symlink." - .to_string(), - )); - } - Ok(_) => false, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir_all(parent)?; - true - } - Err(error) => return Err(PackageError::Io(error)), - }; - - #[cfg(unix)] - if created_parent || self.manage_parent_permissions { - use std::os::unix::fs::PermissionsExt; - let parent_handle = open_directory_no_follow(parent)?; - parent_handle.set_permissions(std::fs::Permissions::from_mode(0o700))?; - } - } - - let mut token_owned = token.to_string(); - let mut data = AuthData { - token: Some(token_owned.clone()), - registry: Some(registry_origin), - }; - - let mut content = serde_json::to_string_pretty(&data) - .map_err(|e| PackageError::General(format!("Failed to serialize auth data: {}", e)))?; - - let write_result = self.write_auth_file(content.as_bytes()); - - // Zeroize secrets to prevent them lingering in memory. - content.zeroize(); - token_owned.zeroize(); - if let Some(ref mut t) = data.token { - t.zeroize(); - } - if let Some(ref mut r) = data.registry { - r.zeroize(); - } - - write_result - } - - /// Remove the stored authentication token. - pub fn clear_token(&self) -> Result<(), PackageError> { - match std::fs::symlink_metadata(&self.auth_file) { - Ok(_) => std::fs::remove_file(&self.auth_file)?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(PackageError::Io(error)), - } - Ok(()) - } - - /// Check whether anything exists at the credentials path without parsing - /// or following it. This lets logout recover malformed and symlinked auth. - pub fn credentials_file_exists(&self) -> Result { - match std::fs::symlink_metadata(&self.auth_file) { - Ok(_) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(PackageError::Io(error)), - } - } - - /// Check if we have a stored token. - pub fn is_authenticated(&self) -> bool { - self.get_credentials().ok().flatten().is_some() - } - - fn write_auth_file(&self, content: &[u8]) -> Result<(), PackageError> { - use std::io::Write; - - let parent = self - .auth_file - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")); - let mut temp = tempfile::Builder::new() - .prefix(".auth-") - .tempfile_in(parent)?; - - // NamedTempFile is private by default. Set the mode before writing as - // an explicit invariant so the token is never briefly world-readable. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - temp.as_file() - .set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - - temp.write_all(content)?; - temp.as_file().sync_all()?; - let persisted = temp - .persist(&self.auth_file) - .map_err(|error| PackageError::Io(error.error))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - persisted.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - persisted.sync_all()?; - Ok(()) - } -} - -fn read_auth_file_no_follow(path: &std::path::Path) -> Result { - use std::io::Read; - - let mut options = std::fs::OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - } - let file = options.open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() || metadata.len() > MAX_AUTH_FILE_BYTES { - return Err(PackageError::General( - "The credentials file is not a safe regular file.".to_string(), - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.take(MAX_AUTH_FILE_BYTES + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_AUTH_FILE_BYTES { - return Err(PackageError::General( - "The credentials file exceeds the 64 KiB safety limit.".to_string(), - )); - } - String::from_utf8(bytes) - .map_err(|_| PackageError::General("The credentials file is not valid UTF-8.".to_string())) -} - -#[cfg(unix)] -fn open_directory_no_follow(path: &std::path::Path) -> Result { - let mut options = std::fs::OpenOptions::new(); - options.read(true); - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW); - let directory = options.open(path)?; - if !directory.metadata()?.is_dir() { - return Err(PackageError::General( - "The credentials directory is not a directory.".to_string(), - )); - } - Ok(directory) -} - -/// Parse a registry setting and reduce it to a canonical HTTPS origin. -/// -/// Manifests traditionally contain a bare host (for example `wflhub.org`), -/// while auth files may contain either that form or a full HTTPS URL. Paths, -/// userinfo, queries, and fragments are rejected because credentials are -/// scoped to an origin, not to an attacker-controlled URL string. -pub fn normalize_registry_origin(registry: &str) -> Result { - let registry = registry.trim(); - if registry.is_empty() { - return Err(PackageError::General( - "Registry address cannot be empty.".to_string(), - )); - } - - let candidate = if registry.contains("://") { - registry.to_string() - } else { - format!("https://{}", registry) - }; - let url = reqwest::Url::parse(&candidate) - .map_err(|_| PackageError::General("Registry address is not a valid URL.".to_string()))?; - - if url.scheme() != "https" { - return Err(PackageError::General( - "Registry credentials may only be sent to an HTTPS registry.".to_string(), - )); - } - if url.host_str().is_none() { - return Err(PackageError::General( - "Registry address must include a host.".to_string(), - )); - } - if !url.username().is_empty() || url.password().is_some() { - return Err(PackageError::General( - "Registry address must not contain a username or password.".to_string(), - )); - } - if url.query().is_some() || url.fragment().is_some() { - return Err(PackageError::General( - "Registry address must not contain a query or fragment.".to_string(), - )); - } - if url.path() != "/" && !url.path().is_empty() { - return Err(PackageError::General( - "Registry address must not contain a path.".to_string(), - )); - } - - Ok(url.origin().ascii_serialization()) -} - -/// Get the user's home directory. -fn get_home_dir() -> Result { - #[cfg(target_os = "windows")] - { - std::env::var("USERPROFILE") - .map(PathBuf::from) - .map_err(|_| PackageError::General("Could not determine home directory".to_string())) - } - #[cfg(not(target_os = "windows"))] - { - std::env::var("HOME") - .map(PathBuf::from) - .map_err(|_| PackageError::General("Could not determine home directory".to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_store_and_retrieve_token() { - let temp = TempDir::new().unwrap(); - let auth = AuthManager::with_path(temp.path().join("auth.json")); - - assert!(!auth.is_authenticated()); - assert!(auth.get_token().unwrap().is_none()); - - auth.store_token("test-token-123", "wflhub.org").unwrap(); - assert!(auth.is_authenticated()); - assert_eq!(auth.get_token().unwrap().unwrap(), "test-token-123"); - assert_eq!( - auth.get_credentials().unwrap().unwrap().registry_origin(), - "https://wflhub.org" - ); - } - - #[test] - fn test_clear_token() { - let temp = TempDir::new().unwrap(); - let auth = AuthManager::with_path(temp.path().join("auth.json")); - - auth.store_token("token", "wflhub.org").unwrap(); - assert!(auth.is_authenticated()); - - auth.clear_token().unwrap(); - assert!(!auth.is_authenticated()); - } - - #[test] - fn test_clear_token_removes_malformed_credentials() { - let temp = TempDir::new().unwrap(); - let path = temp.path().join("auth.json"); - let auth = AuthManager::with_path(path.clone()); - - for malformed in [ - "not json", - r#"{"token":"secret"}"#, - r#"{"token":"secret","registry":"http://registry.example"}"#, - ] { - std::fs::write(&path, malformed).unwrap(); - assert!(auth.get_credentials().is_err()); - assert!(auth.credentials_file_exists().unwrap()); - auth.clear_token().unwrap(); - assert!(!auth.credentials_file_exists().unwrap()); - auth.store_token("replacement", "registry.example").unwrap(); - assert!(auth.is_authenticated()); - auth.clear_token().unwrap(); - } - } - - #[test] - fn test_normalize_registry_origin() { - assert_eq!( - normalize_registry_origin("wflhub.org").unwrap(), - "https://wflhub.org" - ); - assert_eq!( - normalize_registry_origin("HTTPS://WFLHUB.ORG:443/").unwrap(), - "https://wflhub.org" - ); - assert!(normalize_registry_origin("http://wflhub.org").is_err()); - assert!(normalize_registry_origin("wflhub.org@evil.example").is_err()); - assert!(normalize_registry_origin("wflhub.org/api").is_err()); - } - - #[cfg(unix)] - #[test] - fn test_store_token_replaces_symlink_without_touching_target() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let target = temp.path().join("target.txt"); - std::fs::write(&target, "keep me").unwrap(); - let auth_path = temp.path().join("auth.json"); - symlink(&target, &auth_path).unwrap(); - let auth = AuthManager::with_path(auth_path.clone()); - - auth.store_token("secret", "wflhub.org").unwrap(); - - assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); - assert!( - !std::fs::symlink_metadata(&auth_path) - .unwrap() - .file_type() - .is_symlink() - ); - assert_eq!(auth.get_token().unwrap().as_deref(), Some("secret")); - } - - #[cfg(unix)] - #[test] - fn test_store_token_creates_private_file_and_directory() { - use std::os::unix::fs::PermissionsExt; - - let temp = TempDir::new().unwrap(); - let auth_dir = temp.path().join("credentials"); - let auth_path = auth_dir.join("auth.json"); - let auth = AuthManager::with_path(auth_path.clone()); - - auth.store_token("secret", "wflhub.org").unwrap(); - - assert_eq!( - std::fs::metadata(&auth_path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - assert_eq!( - std::fs::metadata(&auth_dir).unwrap().permissions().mode() & 0o777, - 0o700 - ); - } - - #[cfg(unix)] - #[test] - fn test_custom_auth_path_does_not_chmod_existing_parent() { - use std::os::unix::fs::PermissionsExt; - - let temp = TempDir::new().unwrap(); - let parent = temp.path().join("custom"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o750)).unwrap(); - let auth = AuthManager::with_path(parent.join("auth.json")); - - auth.store_token("secret", "wflhub.org").unwrap(); - assert_eq!( - std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777, - 0o750 - ); - } -} diff --git a/crates/wflpkg/src/registry/mod.rs b/crates/wflpkg/src/registry/mod.rs deleted file mode 100644 index 01930695..00000000 --- a/crates/wflpkg/src/registry/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod advisory; -pub mod api; -pub mod auth; diff --git a/crates/wflpkg/src/resolver/algorithm.rs b/crates/wflpkg/src/resolver/algorithm.rs deleted file mode 100644 index 8e76d475..00000000 --- a/crates/wflpkg/src/resolver/algorithm.rs +++ /dev/null @@ -1,205 +0,0 @@ -use std::collections::HashMap; - -use crate::error::PackageError; -use crate::manifest::Dependency; -use crate::manifest::version::{Version, VersionConstraint}; -use crate::resolver::ResolvedSet; - -/// A dependency resolver that uses a greedy algorithm. -/// -/// Algorithm: -/// 1. Collect all constraints for each package from the manifest and transitive deps -/// 2. Topologically sort the dependency graph -/// 3. For each package, find the highest version satisfying all constraints -/// 4. Report conflicts with actionable error messages -#[derive(Default)] -pub struct DependencyResolver { - /// Available versions per package (from registry/cache) - available: HashMap>, - /// Workspace member names (prefer local resolution) - workspace_members: Vec, -} - -impl DependencyResolver { - pub fn new() -> Self { - Self::default() - } - - /// Register available versions for a package. - pub fn add_available(&mut self, name: &str, versions: Vec) { - self.available.insert(name.to_string(), versions); - } - - /// Register workspace members for local resolution preference. - pub fn set_workspace_members(&mut self, members: Vec) { - self.workspace_members = members; - } - - /// Resolve dependencies from a list of direct dependencies. - pub fn resolve(&self, dependencies: &[Dependency]) -> Result { - let mut constraints: HashMap> = HashMap::new(); - - // Collect all constraints (source is "project" for direct deps) - for dep in dependencies { - constraints - .entry(dep.name.clone()) - .or_default() - .push((dep.constraint.clone(), "your project".to_string())); - } - - // Resolve each package - let mut resolved = ResolvedSet::default(); - - for (name, pkg_constraints) in &constraints { - // Check if it's a workspace member - if self.workspace_members.contains(name) { - // Workspace members are resolved locally; skip registry resolution - continue; - } - - let versions = - self.available - .get(name) - .ok_or_else(|| PackageError::PackageNotFound { - name: name.clone(), - suggestions: self.find_similar_names(name), - })?; - - // Find highest version satisfying all constraints - let mut matching: Vec<&Version> = versions - .iter() - .filter(|v| pkg_constraints.iter().all(|(c, _)| c.matches(v))) - .collect(); - - matching.sort(); - - if let Some(best) = matching.last() { - resolved.packages.insert(name.clone(), (*best).clone()); - } else { - // Find which constraints conflict - if pkg_constraints.len() >= 2 { - let (c_a, src_a) = &pkg_constraints[0]; - let (c_b, src_b) = &pkg_constraints[1]; - return Err(PackageError::VersionConflict { - package: name.clone(), - constraint_a: c_a.to_string(), - source_a: src_a.clone(), - constraint_b: c_b.to_string(), - source_b: src_b.clone(), - }); - } else { - return Err(PackageError::General(format!( - "I could not find a version of \"{}\" matching {}.\n\n\ - Available versions: {}", - name, - pkg_constraints[0].0, - versions - .iter() - .map(|v| v.to_string()) - .collect::>() - .join(", ") - ))); - } - } - } - - Ok(resolved) - } - - /// Find package names similar to the given name (for suggestions). - fn find_similar_names(&self, name: &str) -> Vec { - self.available - .keys() - .filter(|k| { - // Simple similarity: shared prefix or edit distance heuristic - let shorter = name.len().min(k.len()); - if shorter < 3 { - return false; - } - let shared = name - .chars() - .zip(k.chars()) - .take_while(|(a, b)| a == b) - .count(); - shared >= shorter / 2 || k.contains(name) || name.contains(k.as_str()) - }) - .cloned() - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resolve_simple() { - let mut resolver = DependencyResolver::new(); - resolver.add_available( - "http-client", - vec![ - Version::new(26, 1, Some(1)), - Version::new(26, 1, Some(2)), - Version::new(26, 1, Some(3)), - ], - ); - - let deps = vec![Dependency { - name: "http-client".to_string(), - constraint: VersionConstraint::OrNewer(Version::new(26, 1, None)), - dev_only: false, - }]; - - let result = resolver.resolve(&deps).unwrap(); - assert_eq!( - result.packages.get("http-client").unwrap(), - &Version::new(26, 1, Some(3)) - ); - } - - #[test] - fn test_resolve_exact() { - let mut resolver = DependencyResolver::new(); - resolver.add_available( - "json-parser", - vec![Version::new(25, 12, Some(5)), Version::new(25, 12, Some(8))], - ); - - let deps = vec![Dependency { - name: "json-parser".to_string(), - constraint: VersionConstraint::Exactly(Version::new(25, 12, Some(5))), - dev_only: false, - }]; - - let result = resolver.resolve(&deps).unwrap(); - assert_eq!( - result.packages.get("json-parser").unwrap(), - &Version::new(25, 12, Some(5)) - ); - } - - #[test] - fn test_resolve_not_found() { - let resolver = DependencyResolver::new(); - let deps = vec![Dependency { - name: "nonexistent".to_string(), - constraint: VersionConstraint::AnyVersion, - dev_only: false, - }]; - assert!(resolver.resolve(&deps).is_err()); - } - - #[test] - fn test_resolve_no_matching_version() { - let mut resolver = DependencyResolver::new(); - resolver.add_available("old-pkg", vec![Version::new(24, 1, Some(1))]); - - let deps = vec![Dependency { - name: "old-pkg".to_string(), - constraint: VersionConstraint::OrNewer(Version::new(26, 1, None)), - dev_only: false, - }]; - - assert!(resolver.resolve(&deps).is_err()); - } -} diff --git a/crates/wflpkg/src/resolver/mod.rs b/crates/wflpkg/src/resolver/mod.rs deleted file mode 100644 index e8936932..00000000 --- a/crates/wflpkg/src/resolver/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub mod algorithm; -pub mod package_path; - -use std::collections::HashMap; - -use crate::manifest::version::Version; - -/// The result of dependency resolution: a map of package name to exact version. -#[derive(Debug, Clone, Default)] -pub struct ResolvedSet { - pub packages: HashMap, -} diff --git a/crates/wflpkg/src/resolver/package_path.rs b/crates/wflpkg/src/resolver/package_path.rs deleted file mode 100644 index f3cf89f4..00000000 --- a/crates/wflpkg/src/resolver/package_path.rs +++ /dev/null @@ -1,248 +0,0 @@ -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; - -use crate::error::PackageError; -use crate::manifest::ProjectManifest; -use crate::manifest::parser::validate_package_name; - -fn not_installed(name: &str) -> PackageError { - PackageError::General(format!( - "The package \"{}\" is not installed.\n\n\ - To install it, run:\n\ - \x20 wfl add {}", - name, name - )) -} - -fn verified_package_directory(name: &str, project_dir: &Path) -> Result { - let canonical_project = project_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify project directory \"{}\": {}", - project_dir.display(), - error - )) - })?; - let packages_dir = canonical_project.join("packages"); - let packages_metadata = match std::fs::symlink_metadata(&packages_dir) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Err(not_installed(name)), - Err(error) => return Err(error.into()), - }; - if packages_metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": packages directory \"{}\" is a symbolic link.", - name, - packages_dir.display() - ))); - } - if !packages_metadata.is_dir() { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": \"{}\" is not a directory.", - name, - packages_dir.display() - ))); - } - - let canonical_packages = packages_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify packages directory \"{}\": {}", - packages_dir.display(), - error - )) - })?; - if canonical_packages.parent() != Some(canonical_project.as_path()) { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": packages directory escapes the project.", - name - ))); - } - - let package_dir = canonical_packages.join(name); - let package_metadata = match std::fs::symlink_metadata(&package_dir) { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Err(not_installed(name)), - Err(error) => return Err(error.into()), - }; - if package_metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": package directory \"{}\" is a symbolic link.", - name, - package_dir.display() - ))); - } - if !package_metadata.is_dir() { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": \"{}\" is not a directory.", - name, - package_dir.display() - ))); - } - - let canonical_package = package_dir.canonicalize().map_err(|error| { - PackageError::General(format!( - "Could not verify package directory \"{}\": {}", - package_dir.display(), - error - )) - })?; - if canonical_package.parent() != Some(canonical_packages.as_path()) { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": package directory escapes the packages directory.", - name - ))); - } - - Ok(canonical_package) -} - -/// Resolve the entry point for a package installed in `packages//`. -pub fn resolve_package_entry(name: &str, project_dir: &Path) -> Result { - validate_package_name(name)?; - let package_dir = verified_package_directory(name, project_dir)?; - - // Look for project.wfl in the package directory - let manifest_path = package_dir.join("project.wfl"); - let manifest_metadata = match std::fs::symlink_metadata(&manifest_path) { - Ok(metadata) => Some(metadata), - Err(error) if error.kind() == ErrorKind::NotFound => None, - Err(error) => return Err(error.into()), - }; - if let Some(manifest_metadata) = manifest_metadata { - if manifest_metadata.file_type().is_symlink() { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": package manifest \"{}\" is a symbolic link.", - name, - manifest_path.display() - ))); - } - if !manifest_metadata.is_file() { - return Err(PackageError::General(format!( - "Refusing to resolve package \"{}\": package manifest \"{}\" is not a regular file.", - name, - manifest_path.display() - ))); - } - let manifest = ProjectManifest::load(&manifest_path)?; - let entry = manifest.entry_point(); - let entry_path = package_dir.join(entry); - if entry_path.exists() { - // Canonicalize both paths to resolve symlinks and `..` components, - // then verify the entry point is inside the package directory. - let canon_pkg = package_dir.canonicalize().map_err(|e| { - PackageError::General(format!( - "Could not canonicalize package directory \"{}\": {}", - package_dir.display(), - e - )) - })?; - let canon_entry = entry_path.canonicalize().map_err(|e| { - PackageError::General(format!( - "Could not canonicalize entry point \"{}\": {}", - entry_path.display(), - e - )) - })?; - if !canon_entry.starts_with(&canon_pkg) { - return Err(PackageError::General(format!( - "The package \"{}\" declares an unsafe entry point \"{}\" \ - that escapes the package directory.", - name, entry - ))); - } - return Ok(canon_entry); - } - return Err(PackageError::General(format!( - "The package \"{}\" declares entry point \"{}\" but the file does not exist.", - name, entry - ))); - } - - // Fallback: look for src/main.wfl - let default_entry = package_dir.join("src").join("main.wfl"); - if default_entry.exists() { - let canon = default_entry.canonicalize().map_err(|e| { - PackageError::General(format!( - "Could not canonicalize fallback entry \"{}\": {}", - default_entry.display(), - e - )) - })?; - let canon_pkg = package_dir.canonicalize().map_err(|e| { - PackageError::General(format!( - "Could not canonicalize package directory \"{}\": {}", - package_dir.display(), - e - )) - })?; - if !canon.starts_with(&canon_pkg) { - return Err(PackageError::General(format!( - "Fallback entry point escapes the package directory for \"{}\".", - name - ))); - } - return Ok(canon); - } - - // Fallback: look for main.wfl in package root - let root_main = package_dir.join("main.wfl"); - if root_main.exists() { - let canon = root_main.canonicalize().map_err(|e| { - PackageError::General(format!( - "Could not canonicalize fallback entry \"{}\": {}", - root_main.display(), - e - )) - })?; - let canon_pkg = package_dir.canonicalize().map_err(|e| { - PackageError::General(format!( - "Could not canonicalize package directory \"{}\": {}", - package_dir.display(), - e - )) - })?; - if !canon.starts_with(&canon_pkg) { - return Err(PackageError::General(format!( - "Fallback entry point escapes the package directory for \"{}\".", - name - ))); - } - return Ok(canon); - } - - Err(PackageError::General(format!( - "I could not find an entry point for the package \"{}\".\n\n\ - The package directory exists at {}\n\ - but does not contain a project.wfl, src/main.wfl, or main.wfl file.", - name, - package_dir.display() - ))) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_resolve_with_manifest() { - let temp = TempDir::new().unwrap(); - let pkg_dir = temp.path().join("packages").join("my-lib"); - std::fs::create_dir_all(pkg_dir.join("src")).unwrap(); - std::fs::write( - pkg_dir.join("project.wfl"), - "name is my-lib\nversion is 26.1.1\ndescription is Test\nentry is src/main.wfl", - ) - .unwrap(); - std::fs::write(pkg_dir.join("src").join("main.wfl"), "// entry").unwrap(); - - let result = resolve_package_entry("my-lib", temp.path()).unwrap(); - assert!(result.ends_with("main.wfl")); - } - - #[test] - fn test_resolve_not_installed() { - let temp = TempDir::new().unwrap(); - let result = resolve_package_entry("missing-pkg", temp.path()); - assert!(result.is_err()); - } -} diff --git a/crates/wflpkg/src/workspace/mod.rs b/crates/wflpkg/src/workspace/mod.rs deleted file mode 100644 index 26bf3c93..00000000 --- a/crates/wflpkg/src/workspace/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod parser; - -/// A workspace definition parsed from `workspace.wfl`. -#[derive(Debug, Clone)] -pub struct Workspace { - pub name: String, - pub members: Vec, -} diff --git a/crates/wflpkg/src/workspace/parser.rs b/crates/wflpkg/src/workspace/parser.rs deleted file mode 100644 index 2c2745a3..00000000 --- a/crates/wflpkg/src/workspace/parser.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::path::Path; - -use crate::error::PackageError; -use crate::workspace::Workspace; - -/// Parse a `workspace.wfl` file from the given directory. -pub fn parse_workspace_file(workspace_dir: &Path) -> Result { - let path = workspace_dir.join("workspace.wfl"); - if !path.exists() { - return Err(PackageError::WorkspaceError( - "No workspace.wfl file found in this directory.".to_string(), - )); - } - - let content = std::fs::read_to_string(&path)?; - parse_workspace(&content) -} - -/// Parse workspace content. -pub fn parse_workspace(content: &str) -> Result { - let mut name = String::new(); - let mut members = Vec::new(); - let mut line_num = 0; - - for raw_line in content.lines() { - line_num += 1; - let line = raw_line.trim(); - - if line.is_empty() || line.starts_with("//") { - continue; - } - - if let Some(rest) = line.strip_prefix("name is ") { - name = rest.trim().to_string(); - } else if let Some(rest) = line.strip_prefix("member is ") { - members.push(rest.trim().to_string()); - } else { - return Err(PackageError::WorkspaceError(format!( - "Unexpected line {} in workspace.wfl: {}", - line_num, line - ))); - } - } - - if name.is_empty() { - return Err(PackageError::WorkspaceError( - "workspace.wfl is missing the 'name is ...' field.".to_string(), - )); - } - - if members.is_empty() { - return Err(PackageError::WorkspaceError( - "workspace.wfl has no members defined. Add 'member is path/to/package'.".to_string(), - )); - } - - Ok(Workspace { name, members }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_workspace() { - let content = "\ -// workspace.wfl - -name is my-organization - -member is packages/core -member is packages/web-server -member is packages/cli-tool -"; - let ws = parse_workspace(content).unwrap(); - assert_eq!(ws.name, "my-organization"); - assert_eq!(ws.members.len(), 3); - assert_eq!(ws.members[0], "packages/core"); - assert_eq!(ws.members[1], "packages/web-server"); - assert_eq!(ws.members[2], "packages/cli-tool"); - } - - #[test] - fn test_parse_workspace_missing_name() { - let content = "member is packages/core"; - assert!(parse_workspace(content).is_err()); - } - - #[test] - fn test_parse_workspace_no_members() { - let content = "name is my-org"; - assert!(parse_workspace(content).is_err()); - } -} diff --git a/crates/wflpkg/tests/error_handling.rs b/crates/wflpkg/tests/error_handling.rs deleted file mode 100644 index ef2865ef..00000000 --- a/crates/wflpkg/tests/error_handling.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Tests for PackageError Display formatting, error conversions, and edge cases. - -use wflpkg::PackageError; - -// --------------------------------------------------------------------------- -// Error Display formatting (one per variant) -// --------------------------------------------------------------------------- - -#[test] -fn test_display_manifest_not_found() { - let err = PackageError::ManifestNotFound("/some/dir".to_string()); - let msg = err.to_string(); - assert!( - msg.contains("could not find a project.wfl"), - "expected 'could not find a project.wfl', got: {msg}" - ); - assert!(msg.contains("/some/dir")); -} - -#[test] -fn test_display_manifest_parse_error() { - let err = PackageError::ManifestParseError { - line: 42, - message: "unexpected token".to_string(), - }; - let msg = err.to_string(); - assert!(msg.contains("42"), "should contain line number 42: {msg}"); - assert!(msg.contains("unexpected token")); -} - -#[test] -fn test_display_invalid_package_name() { - let err = PackageError::InvalidPackageName("BAD-NAME".to_string()); - let msg = err.to_string(); - assert!( - msg.contains("not valid"), - "expected 'not valid', got: {msg}" - ); - assert!(msg.contains("BAD-NAME")); -} - -#[test] -fn test_display_invalid_version() { - let err = PackageError::InvalidVersion("abc.def".to_string()); - let msg = err.to_string(); - assert!( - msg.contains("not a valid WFL version"), - "expected version error, got: {msg}" - ); - assert!(msg.contains("abc.def")); -} - -#[test] -fn test_display_invalid_version_constraint() { - let err = PackageError::InvalidVersionConstraint("xyzzy".to_string()); - let msg = err.to_string(); - assert!( - msg.contains("could not understand"), - "expected 'could not understand', got: {msg}" - ); - assert!(msg.contains("xyzzy")); -} - -#[test] -fn test_display_package_not_found_no_suggestions() { - let err = PackageError::PackageNotFound { - name: "nonexistent".to_string(), - suggestions: vec![], - }; - let msg = err.to_string(); - assert!(msg.contains("nonexistent")); - assert!( - !msg.contains("Did you mean"), - "should not suggest with empty suggestions: {msg}" - ); -} - -#[test] -fn test_display_package_not_found_with_suggestions() { - let err = PackageError::PackageNotFound { - name: "htto-client".to_string(), - suggestions: vec!["http-client".to_string()], - }; - let msg = err.to_string(); - assert!(msg.contains("htto-client")); - assert!( - msg.contains("Did you mean"), - "expected 'Did you mean', got: {msg}" - ); - assert!(msg.contains("http-client")); -} - -#[test] -fn test_display_version_conflict() { - let err = PackageError::VersionConflict { - package: "json-parser".to_string(), - constraint_a: "26.1 or newer".to_string(), - source_a: "my-app".to_string(), - constraint_b: "below 26".to_string(), - source_b: "other-pkg".to_string(), - }; - let msg = err.to_string(); - assert!( - msg.contains("version conflict") || msg.contains("conflict"), - "expected conflict mention, got: {msg}" - ); - assert!(msg.contains("my-app")); - assert!(msg.contains("other-pkg")); -} - -#[test] -fn test_display_registry_unreachable() { - let err = PackageError::RegistryUnreachable("https://registry.example.com".to_string()); - let msg = err.to_string(); - assert!( - msg.contains("could not connect"), - "expected 'could not connect', got: {msg}" - ); - assert!(msg.contains("registry.example.com")); -} - -#[test] -fn test_display_not_authenticated() { - let err = PackageError::NotAuthenticated; - let msg = err.to_string(); - assert!( - msg.contains("not logged in"), - "expected 'not logged in', got: {msg}" - ); -} - -#[test] -fn test_display_lockfile_parse_error() { - let err = PackageError::LockFileParseError { - line: 7, - message: "bad checksum".to_string(), - }; - let msg = err.to_string(); - assert!( - msg.contains("project.lock"), - "expected 'project.lock', got: {msg}" - ); - assert!(msg.contains("7")); - assert!(msg.contains("bad checksum")); -} - -#[test] -fn test_display_checksum_mismatch() { - let err = PackageError::ChecksumMismatch { - package: "http-client".to_string(), - expected: "abc123".to_string(), - actual: "def456".to_string(), - }; - let msg = err.to_string(); - assert!(msg.contains("abc123"), "expected hash missing: {msg}"); - assert!(msg.contains("def456"), "actual hash missing: {msg}"); - assert!(msg.contains("http-client")); -} - -#[test] -fn test_display_security_advisory_with_fix() { - let err = PackageError::SecurityAdvisory { - package: "crypto-lib".to_string(), - severity: "HIGH".to_string(), - description: "Buffer overflow".to_string(), - fixed_in: Some("26.2.1".to_string()), - }; - let msg = err.to_string(); - assert!(msg.contains("Fixed in"), "expected 'Fixed in', got: {msg}"); - assert!(msg.contains("26.2.1")); - assert!(msg.contains("crypto-lib")); -} - -#[test] -fn test_display_security_advisory_no_fix() { - let err = PackageError::SecurityAdvisory { - package: "crypto-lib".to_string(), - severity: "LOW".to_string(), - description: "Minor issue".to_string(), - fixed_in: None, - }; - let msg = err.to_string(); - assert!( - !msg.contains("Fixed in"), - "should not contain 'Fixed in' with no fix: {msg}" - ); -} - -#[test] -fn test_display_permission_required() { - let err = PackageError::PermissionRequired { - package: "fs-tools".to_string(), - permissions: vec!["file-access".to_string(), "network-access".to_string()], - }; - let msg = err.to_string(); - assert!(msg.contains("file-access")); - assert!(msg.contains("network-access")); - assert!( - msg.contains("Can read and write files"), - "expected permission description, got: {msg}" - ); -} - -#[test] -fn test_display_workspace_error() { - let err = PackageError::WorkspaceError("bad config".to_string()); - let msg = err.to_string(); - assert!( - msg.contains("Workspace error"), - "expected 'Workspace error', got: {msg}" - ); - assert!(msg.contains("bad config")); -} - -#[test] -fn test_display_general() { - let err = PackageError::General("custom message".to_string()); - let msg = err.to_string(); - assert_eq!(msg, "custom message"); -} - -// --------------------------------------------------------------------------- -// Error conversions -// --------------------------------------------------------------------------- - -#[test] -fn test_from_io_error() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone"); - let pkg_err: PackageError = io_err.into(); - let msg = pkg_err.to_string(); - assert!(msg.contains("file gone"), "IO message preserved: {msg}"); -} - -// --------------------------------------------------------------------------- -// Manifest edge cases -// --------------------------------------------------------------------------- - -#[test] -fn test_manifest_empty_content() { - let result = wflpkg::manifest::parser::parse_manifest(""); - assert!(result.is_err(), "empty manifest should fail"); -} - -#[test] -fn test_manifest_missing_version() { - let content = "name is foo\ndescription is bar"; - let result = wflpkg::manifest::parser::parse_manifest(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("version"), - "error should mention version: {msg}" - ); -} - -#[test] -fn test_manifest_missing_description() { - let content = "name is foo\nversion is 26.1.1"; - let result = wflpkg::manifest::parser::parse_manifest(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("description"), - "error should mention description: {msg}" - ); -} - -// --------------------------------------------------------------------------- -// Version edge cases -// --------------------------------------------------------------------------- - -#[test] -fn test_version_parse_non_numeric() { - let result = wflpkg::Version::parse("abc.def"); - assert!(result.is_err(), "non-numeric version should fail"); -} - -#[test] -fn test_version_constraint_parse_gibberish() { - let result = wflpkg::VersionConstraint::parse("xyzzy"); - assert!(result.is_err(), "gibberish constraint should fail"); -} diff --git a/crates/wflpkg/tests/security_tests.rs b/crates/wflpkg/tests/security_tests.rs deleted file mode 100644 index d1b31f20..00000000 --- a/crates/wflpkg/tests/security_tests.rs +++ /dev/null @@ -1,750 +0,0 @@ -//! Security-focused tests for archive extraction, path traversal, -//! package name validation, and entry-point containment. - -use std::fs; -use tempfile::TempDir; - -// =========================================================================== -// Archive extraction security -// =========================================================================== - -fn build_malicious_archive( - archive_path: &std::path::Path, - entry_type: tar::EntryType, - name: &[u8], - link: &[u8], - data: &[u8], -) { - let file = fs::File::create(archive_path).unwrap(); - let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default()); - let mut tar = tar::Builder::new(enc); - - let mut header = tar::Header::new_gnu(); - header.set_entry_type(entry_type); - header.set_size(data.len() as u64); - header.set_mode(0o644); - { - let gnu = header.as_gnu_mut().unwrap(); - gnu.name[..name.len()].copy_from_slice(name); - if !link.is_empty() { - gnu.linkname[..link.len()].copy_from_slice(link); - } - } - header.set_cksum(); - tar.append(&header, data).unwrap(); - let enc = tar.into_inner().unwrap(); - enc.finish().unwrap(); -} - -#[test] -fn test_extract_archive_rejects_absolute_path() { - let temp = TempDir::new().unwrap(); - let archive_path = temp.path().join("bad.wflpkg"); - build_malicious_archive( - &archive_path, - tar::EntryType::Regular, - b"/etc/shadow", - b"", - b"malicious content", - ); - - let dest = temp.path().join("output"); - fs::create_dir_all(&dest).unwrap(); - let result = wflpkg::archive::extract_archive(&archive_path, &dest); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("absolute path"), - "expected 'absolute path', got: {msg}" - ); -} - -#[test] -fn test_extract_archive_rejects_parent_traversal() { - let temp = TempDir::new().unwrap(); - let archive_path = temp.path().join("bad.wflpkg"); - build_malicious_archive( - &archive_path, - tar::EntryType::Regular, - b"../../etc/passwd", - b"", - b"escape", - ); - - let dest = temp.path().join("output"); - fs::create_dir_all(&dest).unwrap(); - let result = wflpkg::archive::extract_archive(&archive_path, &dest); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("path traversal"), - "expected 'path traversal', got: {msg}" - ); -} - -#[test] -fn test_extract_archive_rejects_symlink_entry() { - let temp = TempDir::new().unwrap(); - let archive_path = temp.path().join("bad.wflpkg"); - build_malicious_archive( - &archive_path, - tar::EntryType::Symlink, - b"evil-link", - b"/etc/passwd", - b"", - ); - - let dest = temp.path().join("output"); - fs::create_dir_all(&dest).unwrap(); - let result = wflpkg::archive::extract_archive(&archive_path, &dest); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("symlink or hard link"), - "expected 'symlink or hard link', got: {msg}" - ); -} - -#[test] -fn test_extract_archive_rejects_hardlink_entry() { - let temp = TempDir::new().unwrap(); - let archive_path = temp.path().join("bad.wflpkg"); - build_malicious_archive( - &archive_path, - tar::EntryType::Link, - b"hard-link", - b"/etc/shadow", - b"", - ); - - let dest = temp.path().join("output"); - fs::create_dir_all(&dest).unwrap(); - let result = wflpkg::archive::extract_archive(&archive_path, &dest); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("symlink or hard link"), - "expected 'symlink or hard link', got: {msg}" - ); -} - -#[cfg(unix)] -#[test] -fn test_extract_archive_rejects_preexisting_symlink_ancestor() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let archive_path = temp.path().join("bad.wflpkg"); - build_malicious_archive( - &archive_path, - tar::EntryType::Regular, - b"linked/escaped.txt", - b"", - b"escape", - ); - - let dest = temp.path().join("output"); - let outside = temp.path().join("outside"); - fs::create_dir_all(&dest).unwrap(); - fs::create_dir_all(&outside).unwrap(); - let sentinel = outside.join("sentinel.txt"); - fs::write(&sentinel, "must survive").unwrap(); - symlink(&outside, dest.join("linked")).unwrap(); - - let result = wflpkg::archive::extract_archive(&archive_path, &dest); - assert!( - result.is_err(), - "a pre-existing symlink ancestor must be rejected" - ); - assert!(sentinel.exists(), "outside content must survive extraction"); - assert!( - !outside.join("escaped.txt").exists(), - "archive content must not escape the destination" - ); -} - -#[test] -fn test_create_archive_excludes_expected_dirs() { - let temp = TempDir::new().unwrap(); - let src = temp.path().join("project"); - fs::create_dir_all(src.join("src")).unwrap(); - fs::create_dir_all(src.join("packages")).unwrap(); - fs::create_dir_all(src.join(".git")).unwrap(); - fs::create_dir_all(src.join("node_modules")).unwrap(); - fs::create_dir_all(src.join("target")).unwrap(); - fs::write( - src.join("project.wfl"), - "name is test\nversion is 26.1.0\ndescription is Test", - ) - .unwrap(); - fs::write(src.join("src/main.wfl"), "display \"hi\"").unwrap(); - fs::write(src.join(".gitignore"), "target/").unwrap(); - fs::write(src.join("project.lock"), "// lock").unwrap(); - fs::write(src.join("packages/dep.wfl"), "// dep").unwrap(); - - let archive_path = temp.path().join("test.wflpkg"); - wflpkg::archive::create_archive(&src, &archive_path).unwrap(); - - let dest = temp.path().join("extracted"); - wflpkg::archive::extract_archive(&archive_path, &dest).unwrap(); - - assert!(dest.join("project.wfl").exists()); - assert!(dest.join("src/main.wfl").exists()); - assert!(!dest.join("packages").exists()); - assert!(!dest.join(".git").exists()); - assert!(!dest.join("node_modules").exists()); - assert!(!dest.join("target").exists()); - assert!(!dest.join(".gitignore").exists()); - assert!(!dest.join("project.lock").exists()); -} - -/// Regression test: the checksum published alongside a package must be -/// computed over the *project directory* (minus excluded dirs), NOT over -/// the archive file. When the recipient extracts the archive and runs -/// `compute_checksum` on the extracted tree the result must match. -#[test] -fn test_checksum_of_project_dir_matches_extracted_archive() { - let temp = TempDir::new().unwrap(); - - let src = temp.path().join("project"); - fs::create_dir_all(src.join("src")).unwrap(); - fs::write( - src.join("project.wfl"), - "name is test\nversion is 26.1.0\ndescription is Test", - ) - .unwrap(); - fs::write(src.join("src/main.wfl"), "display \"hello\"").unwrap(); - - // Add every entry from EXCLUDED_NAMES so checksum and archive both skip them - fs::create_dir_all(src.join("packages")).unwrap(); - fs::write(src.join("packages/dep.wfl"), "// dep").unwrap(); - fs::create_dir_all(src.join(".git")).unwrap(); - fs::write(src.join(".git/HEAD"), "ref: refs/heads/main").unwrap(); - fs::create_dir_all(src.join("node_modules")).unwrap(); - fs::write(src.join("node_modules/mod.js"), "//mod").unwrap(); - fs::create_dir_all(src.join("target")).unwrap(); - fs::write(src.join("target/debug"), "bin").unwrap(); - fs::write(src.join(".gitignore"), "target/\n").unwrap(); - fs::write(src.join("project.lock"), "// lock\n").unwrap(); - - // Checksum over the project directory (skips EXCLUDED_NAMES) - let checksum_before = wflpkg::checksum::compute_checksum(&src).unwrap(); - - // Create archive and extract it - let archive_path = temp.path().join("test.wflpkg"); - wflpkg::archive::create_archive(&src, &archive_path).unwrap(); - - let dest = temp.path().join("extracted"); - wflpkg::archive::extract_archive(&archive_path, &dest).unwrap(); - - // Checksum over the extracted directory must equal the original - let checksum_after = wflpkg::checksum::compute_checksum(&dest).unwrap(); - - assert_eq!( - checksum_before, checksum_after, - "checksum of project dir should match checksum of extracted archive" - ); - - // Sanity: computing checksum over the archive *file* gives a different value - let checksum_archive = wflpkg::checksum::compute_checksum(&archive_path).unwrap(); - assert_ne!( - checksum_before, checksum_archive, - "checksum of archive file should differ from checksum of project dir" - ); -} - -// =========================================================================== -// Package name validation (path traversal) -// =========================================================================== - -#[test] -fn test_resolve_package_rejects_slash_in_name() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::resolver::package_path::resolve_package_entry("../escape", temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not valid"), - "expected exact package-name validation, got: {msg}" - ); -} - -#[test] -fn test_resolve_package_rejects_backslash_in_name() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::resolver::package_path::resolve_package_entry("foo\\bar", temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not valid"), - "expected exact package-name validation, got: {msg}" - ); -} - -#[test] -fn test_resolve_package_rejects_dotdot_in_name() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::resolver::package_path::resolve_package_entry("..", temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not valid"), - "expected exact package-name validation, got: {msg}" - ); -} - -#[test] -fn test_resolve_package_rejects_empty_name() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::resolver::package_path::resolve_package_entry("", temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not valid"), - "expected exact package-name validation, got: {msg}" - ); -} - -#[test] -fn test_resolve_package_rejects_windows_path_prefix() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::resolver::package_path::resolve_package_entry("c:escape", temp.path()); - assert!(result.is_err()); - assert!( - result.unwrap_err().to_string().contains("not valid"), - "drive-prefixed names must fail the manifest's package-name rules" - ); -} - -#[cfg(unix)] -#[test] -fn test_resolve_package_rejects_symlinked_packages_root() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let outside = temp.path().join("outside"); - let package = outside.join("my-lib"); - fs::create_dir_all(&package).unwrap(); - fs::write(package.join("main.wfl"), "// outside").unwrap(); - symlink(&outside, temp.path().join("packages")).unwrap(); - - let result = wflpkg::resolver::package_path::resolve_package_entry("my-lib", temp.path()); - assert!( - result.is_err(), - "a symlinked packages root must be rejected" - ); - assert!( - result.unwrap_err().to_string().contains("symbolic link"), - "the error should identify the unsafe package boundary" - ); -} - -#[cfg(unix)] -#[test] -fn test_resolve_package_rejects_symlinked_manifest() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let package = temp.path().join("packages/my-lib"); - fs::create_dir_all(package.join("src")).unwrap(); - fs::write(package.join("src/main.wfl"), "// entry").unwrap(); - let outside_manifest = temp.path().join("outside-project.wfl"); - fs::write( - &outside_manifest, - "name is my-lib\nversion is 26.1.1\ndescription is Outside", - ) - .unwrap(); - symlink(&outside_manifest, package.join("project.wfl")).unwrap(); - - let result = wflpkg::resolver::package_path::resolve_package_entry("my-lib", temp.path()); - assert!( - result.is_err(), - "a symlinked package manifest must be rejected" - ); - assert!( - result.unwrap_err().to_string().contains("symbolic link"), - "the error should identify the unsafe manifest" - ); -} - -#[test] -fn test_resolve_package_fallback_src_main() { - let temp = TempDir::new().unwrap(); - let pkg_dir = temp.path().join("packages").join("my-lib"); - fs::create_dir_all(pkg_dir.join("src")).unwrap(); - fs::write(pkg_dir.join("src/main.wfl"), "// entry").unwrap(); - - let result = wflpkg::resolver::package_path::resolve_package_entry("my-lib", temp.path()); - assert!(result.is_ok()); - let path = result.unwrap(); - assert!(path.ends_with("main.wfl")); -} - -#[test] -fn test_resolve_package_fallback_root_main() { - let temp = TempDir::new().unwrap(); - let pkg_dir = temp.path().join("packages").join("my-lib"); - fs::create_dir_all(&pkg_dir).unwrap(); - fs::write(pkg_dir.join("main.wfl"), "// entry").unwrap(); - - let result = wflpkg::resolver::package_path::resolve_package_entry("my-lib", temp.path()); - assert!(result.is_ok()); - let path = result.unwrap(); - assert!(path.ends_with("main.wfl")); -} - -#[test] -fn test_resolve_package_no_entry_point_found() { - let temp = TempDir::new().unwrap(); - let pkg_dir = temp.path().join("packages").join("empty-pkg"); - fs::create_dir_all(&pkg_dir).unwrap(); - - let result = wflpkg::resolver::package_path::resolve_package_entry("empty-pkg", temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("could not find an entry point"), - "expected 'could not find an entry point', got: {msg}" - ); -} - -// =========================================================================== -// Checksum tests -// =========================================================================== - -#[test] -fn test_checksum_deterministic_across_calls() { - let temp = TempDir::new().unwrap(); - let dir = temp.path().join("project"); - fs::create_dir_all(dir.join("src")).unwrap(); - fs::write(dir.join("file.txt"), "hello world").unwrap(); - fs::write(dir.join("src/code.wfl"), "display \"hi\"").unwrap(); - - let sum1 = wflpkg::checksum::compute_checksum(&dir).unwrap(); - let sum2 = wflpkg::checksum::compute_checksum(&dir).unwrap(); - assert_eq!(sum1, sum2, "checksum should be deterministic"); -} - -#[test] -fn test_checksum_changes_with_content() { - let temp = TempDir::new().unwrap(); - let dir = temp.path().join("project"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("file.txt"), "hello").unwrap(); - - let sum1 = wflpkg::checksum::compute_checksum(&dir).unwrap(); - - fs::write(dir.join("file.txt"), "world").unwrap(); - let sum2 = wflpkg::checksum::compute_checksum(&dir).unwrap(); - - assert_ne!(sum1, sum2, "checksum should change with content"); -} - -#[test] -fn test_checksum_verify_roundtrip() { - let temp = TempDir::new().unwrap(); - let dir = temp.path().join("project"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("file.txt"), "content").unwrap(); - - let checksum = wflpkg::checksum::compute_checksum(&dir).unwrap(); - assert!(wflpkg::checksum::verify_checksum(&dir, &checksum).unwrap()); -} - -#[test] -fn test_checksum_verify_fails_on_tamper() { - let temp = TempDir::new().unwrap(); - let dir = temp.path().join("project"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("file.txt"), "content").unwrap(); - - let checksum = wflpkg::checksum::compute_checksum(&dir).unwrap(); - - fs::write(dir.join("file.txt"), "tampered").unwrap(); - assert!(!wflpkg::checksum::verify_checksum(&dir, &checksum).unwrap()); -} - -#[test] -fn test_checksum_no_hash_ambiguity() { - let temp = TempDir::new().unwrap(); - - let dir1 = temp.path().join("proj1"); - fs::create_dir_all(&dir1).unwrap(); - fs::write(dir1.join("ab"), "cd").unwrap(); - - let dir2 = temp.path().join("proj2"); - fs::create_dir_all(&dir2).unwrap(); - fs::write(dir2.join("a"), "bcd").unwrap(); - - let sum1 = wflpkg::checksum::compute_checksum(&dir1).unwrap(); - let sum2 = wflpkg::checksum::compute_checksum(&dir2).unwrap(); - assert_ne!( - sum1, sum2, - "different file name + content splits should produce different checksums" - ); -} - -// =========================================================================== -// Registry client construction -// =========================================================================== - -#[test] -fn test_registry_client_new_returns_result() { - let client = wflpkg::registry::api::RegistryClient::new("https://example.com"); - assert!(client.is_ok()); -} - -#[test] -fn test_registry_client_strips_trailing_slash() { - let client = wflpkg::registry::api::RegistryClient::new("https://example.com/").unwrap(); - assert_eq!(client.base_url(), "https://example.com"); -} - -// =========================================================================== -// Auth token zeroization (basic structural tests) -// =========================================================================== - -#[test] -fn test_auth_store_and_retrieve() { - let temp = TempDir::new().unwrap(); - let auth_file = temp.path().join("auth.json"); - let auth = wflpkg::registry::auth::AuthManager::with_path(auth_file); - auth.store_token("secret-token-123", "example.com").unwrap(); - - let token = auth.get_token().unwrap(); - assert_eq!(token, Some("secret-token-123".to_string())); -} - -#[test] -fn test_auth_clear_token() { - let temp = TempDir::new().unwrap(); - let auth_file = temp.path().join("auth.json"); - let auth = wflpkg::registry::auth::AuthManager::with_path(auth_file); - auth.store_token("secret", "example.com").unwrap(); - auth.clear_token().unwrap(); - - let token = auth.get_token().unwrap(); - assert_eq!(token, None); -} - -#[test] -fn test_auth_is_authenticated() { - let temp = TempDir::new().unwrap(); - let auth_file = temp.path().join("auth.json"); - let auth = wflpkg::registry::auth::AuthManager::with_path(auth_file); - - assert!(!auth.is_authenticated()); - auth.store_token("token", "example.com").unwrap(); - assert!(auth.is_authenticated()); -} - -#[cfg(unix)] -#[test] -fn test_auth_file_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp = TempDir::new().unwrap(); - let auth_file = temp.path().join("auth.json"); - let auth = wflpkg::registry::auth::AuthManager::with_path(auth_file.clone()); - auth.store_token("secret", "example.com").unwrap(); - - let perms = fs::metadata(&auth_file).unwrap().permissions(); - assert_eq!( - perms.mode() & 0o777, - 0o600, - "auth file should have 0600 permissions" - ); -} - -// =========================================================================== -// Permissions module -// =========================================================================== - -#[test] -fn test_permission_parse_known() { - assert_eq!( - wflpkg::permissions::Permission::parse("file-access"), - wflpkg::permissions::Permission::FileAccess - ); - assert_eq!( - wflpkg::permissions::Permission::parse("network-access"), - wflpkg::permissions::Permission::NetworkAccess - ); - assert_eq!( - wflpkg::permissions::Permission::parse("system-access"), - wflpkg::permissions::Permission::SystemAccess - ); -} - -#[test] -fn test_permission_parse_unknown() { - let perm = wflpkg::permissions::Permission::parse("custom-perm"); - assert_eq!( - perm, - wflpkg::permissions::Permission::Unknown("custom-perm".to_string()) - ); - assert_eq!(perm.name(), "custom-perm"); - assert_eq!(perm.description(), "Unknown permission"); -} - -// =========================================================================== -// Cache module -// =========================================================================== - -#[test] -fn test_cache_store_and_retrieve() { - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - - let src = temp.path().join("src-pkg"); - fs::create_dir_all(&src).unwrap(); - fs::write(src.join("main.wfl"), "display \"hi\"").unwrap(); - - let version = wflpkg::Version::new(26, 1, Some(0)); - cache.store("my-pkg", &version, &src).unwrap(); - assert!(cache.is_cached("my-pkg", &version)); - - let versions = cache.list_versions("my-pkg").unwrap(); - assert_eq!(versions.len(), 1); - assert_eq!(versions[0], version); -} - -#[test] -fn test_cache_install_to_project() { - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - - let src = temp.path().join("src-pkg"); - fs::create_dir_all(&src).unwrap(); - fs::write(src.join("main.wfl"), "display \"hi\"").unwrap(); - - let version = wflpkg::Version::new(26, 1, Some(0)); - cache.store("my-pkg", &version, &src).unwrap(); - - let project = temp.path().join("project"); - fs::create_dir_all(&project).unwrap(); - cache - .install_to_project("my-pkg", &version, &project) - .unwrap(); - - assert!(project.join("packages/my-pkg/main.wfl").exists()); -} - -#[test] -fn test_cache_list_versions_not_cached() { - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - let versions = cache.list_versions("nonexistent").unwrap(); - assert!(versions.is_empty()); -} - -#[test] -fn test_cache_install_not_cached_fails() { - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - let project = temp.path().join("project"); - fs::create_dir_all(&project).unwrap(); - - let version = wflpkg::Version::new(99, 1, Some(0)); - let result = cache.install_to_project("not-cached", &version, &project); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not in the cache"), - "expected 'not in the cache', got: {msg}" - ); -} - -#[test] -fn test_cache_rejects_invalid_package_names() { - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - let version = wflpkg::Version::new(26, 1, Some(0)); - let invalid_name = temp.path().join("outside").to_string_lossy().into_owned(); - - let src = temp.path().join("src-pkg"); - fs::create_dir_all(&src).unwrap(); - fs::write(src.join("main.wfl"), "display \"hi\"").unwrap(); - assert!(!cache.is_cached(&invalid_name, &version)); - assert!(cache.store(&invalid_name, &version, &src).is_err()); - assert!(cache.list_versions(&invalid_name).is_err()); - - let project = temp.path().join("project"); - fs::create_dir_all(&project).unwrap(); - assert!( - cache - .install_to_project(&invalid_name, &version, &project) - .is_err() - ); -} - -#[cfg(unix)] -#[test] -fn test_cache_rejects_symlinked_root() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let outside = temp.path().join("outside-cache"); - fs::create_dir_all(&outside).unwrap(); - let cache_link = temp.path().join("cache-link"); - symlink(&outside, &cache_link).unwrap(); - - match wflpkg::cache::PackageCache::with_dir(cache_link) { - Err(error) => assert!(error.to_string().contains("symbolic link")), - Ok(_) => panic!("a symlinked package cache root must be rejected"), - } -} - -#[cfg(unix)] -#[test] -fn test_cache_store_rejects_symlinked_package_target() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - let version = wflpkg::Version::new(26, 1, Some(0)); - let outside = temp.path().join("outside-package"); - let outside_version = outside.join(version.to_string()); - fs::create_dir_all(&outside_version).unwrap(); - let sentinel = outside_version.join("sentinel.txt"); - fs::write(&sentinel, "must survive").unwrap(); - symlink(&outside, cache.cache_dir().join("my-pkg")).unwrap(); - - let src = temp.path().join("src-pkg"); - fs::create_dir_all(&src).unwrap(); - fs::write(src.join("main.wfl"), "display \"hi\"").unwrap(); - assert!(!cache.is_cached("my-pkg", &version)); - let result = cache.store("my-pkg", &version, &src); - assert!(result.is_err(), "a symlinked cache target must be rejected"); - assert!(sentinel.exists(), "outside cached content must survive"); -} - -#[cfg(unix)] -#[test] -fn test_cache_install_rejects_symlinked_packages_root() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let cache = wflpkg::cache::PackageCache::with_dir(temp.path().join("cache")).unwrap(); - let version = wflpkg::Version::new(26, 1, Some(0)); - let src = temp.path().join("src-pkg"); - fs::create_dir_all(&src).unwrap(); - fs::write(src.join("main.wfl"), "display \"hi\"").unwrap(); - cache.store("my-pkg", &version, &src).unwrap(); - - let project = temp.path().join("project"); - fs::create_dir_all(&project).unwrap(); - let outside = temp.path().join("outside-packages"); - let outside_package = outside.join("my-pkg"); - fs::create_dir_all(&outside_package).unwrap(); - let sentinel = outside_package.join("sentinel.txt"); - fs::write(&sentinel, "must survive").unwrap(); - symlink(&outside, project.join("packages")).unwrap(); - - let result = cache.install_to_project("my-pkg", &version, &project); - assert!( - result.is_err(), - "a symlinked project packages root must be rejected" - ); - assert!(sentinel.exists(), "outside installed content must survive"); -} diff --git a/crates/wflpkg/tests/version_and_lockfile_tests.rs b/crates/wflpkg/tests/version_and_lockfile_tests.rs deleted file mode 100644 index 4c70ece7..00000000 --- a/crates/wflpkg/tests/version_and_lockfile_tests.rs +++ /dev/null @@ -1,579 +0,0 @@ -//! Edge-case tests for version parsing, version constraint matching, -//! lockfile parsing, manifest loading, and error display formatting. - -use tempfile::TempDir; -use wflpkg::error::PackageError; -use wflpkg::manifest::version::{Version, VersionConstraint}; - -// =========================================================================== -// Version parsing edge cases -// =========================================================================== - -#[test] -fn test_version_parse_year_only() { - let v = Version::parse("27").unwrap(); - assert_eq!(v.year, 27); - assert_eq!(v.month, 1); - assert_eq!(v.build, None); -} - -#[test] -fn test_version_parse_year_month() { - let v = Version::parse("26.12").unwrap(); - assert_eq!(v.year, 26); - assert_eq!(v.month, 12); - assert_eq!(v.build, None); -} - -#[test] -fn test_version_parse_full() { - let v = Version::parse("26.1.3").unwrap(); - assert_eq!(v.year, 26); - assert_eq!(v.month, 1); - assert_eq!(v.build, Some(3)); -} - -#[test] -fn test_version_parse_rejects_month_zero() { - let result = Version::parse("26.0"); - assert!(result.is_err(), "month 0 should be invalid"); -} - -#[test] -fn test_version_parse_rejects_month_13() { - let result = Version::parse("26.13"); - assert!(result.is_err(), "month 13 should be invalid"); -} - -#[test] -fn test_version_parse_rejects_month_zero_with_build() { - let result = Version::parse("26.0.1"); - assert!(result.is_err(), "month 0 with build should be invalid"); -} - -#[test] -fn test_version_parse_rejects_month_13_with_build() { - let result = Version::parse("26.13.1"); - assert!(result.is_err(), "month 13 with build should be invalid"); -} - -#[test] -fn test_version_parse_month_boundaries() { - assert!(Version::parse("26.1").is_ok(), "month 1 should be valid"); - assert!(Version::parse("26.12").is_ok(), "month 12 should be valid"); -} - -#[test] -fn test_version_parse_rejects_empty_string() { - let result = Version::parse(""); - assert!(result.is_err(), "empty string should be invalid"); -} - -#[test] -fn test_version_parse_rejects_non_numeric() { - assert!(Version::parse("abc").is_err()); - assert!(Version::parse("26.abc").is_err()); - assert!(Version::parse("26.1.abc").is_err()); -} - -#[test] -fn test_version_parse_rejects_too_many_parts() { - assert!(Version::parse("26.1.3.4").is_err()); -} - -#[test] -fn test_version_parse_trims_whitespace() { - let v = Version::parse(" 26.1.3 ").unwrap(); - assert_eq!(v.year, 26); - assert_eq!(v.month, 1); - assert_eq!(v.build, Some(3)); -} - -#[test] -fn test_version_display_with_build() { - assert_eq!(Version::new(26, 1, Some(3)).to_string(), "26.1.3"); -} - -#[test] -fn test_version_display_without_build() { - assert_eq!(Version::new(26, 1, None).to_string(), "26.1"); -} - -#[test] -fn test_version_ordering() { - let v_25_12_1 = Version::new(25, 12, Some(1)); - let v_26_1_0 = Version::new(26, 1, Some(0)); - let v_26_1_3 = Version::new(26, 1, Some(3)); - let v_26_2_0 = Version::new(26, 2, Some(0)); - - assert!(v_25_12_1 < v_26_1_0); - assert!(v_26_1_0 < v_26_1_3); - assert!(v_26_1_3 < v_26_2_0); -} - -#[test] -fn test_version_equality() { - let a = Version::new(26, 1, Some(3)); - let b = Version::new(26, 1, Some(3)); - assert_eq!(a, b); -} - -#[test] -fn test_version_matches_prefix() { - let v = Version::new(26, 1, Some(5)); - let prefix = Version::new(26, 1, None); - assert!(v.matches_prefix(&prefix)); - let other_prefix = Version::new(26, 2, None); - assert!(!v.matches_prefix(&other_prefix)); -} - -// =========================================================================== -// Version constraint parsing edge cases -// =========================================================================== - -#[test] -fn test_constraint_any_version() { - let c = VersionConstraint::parse("any version").unwrap(); - assert_eq!(c, VersionConstraint::AnyVersion); - assert!(c.matches(&Version::new(1, 1, Some(0)))); - assert!(c.matches(&Version::new(99, 12, Some(999)))); -} - -#[test] -fn test_constraint_or_newer() { - let c = VersionConstraint::parse("26.1 or newer").unwrap(); - assert!(c.matches(&Version::new(26, 1, Some(0)))); - assert!(c.matches(&Version::new(26, 2, Some(0)))); - assert!(c.matches(&Version::new(27, 1, Some(0)))); - assert!(!c.matches(&Version::new(25, 12, Some(0)))); -} - -#[test] -fn test_constraint_exactly_with_build() { - let c = VersionConstraint::parse("26.1.3 exactly").unwrap(); - assert!(c.matches(&Version::new(26, 1, Some(3)))); - assert!(!c.matches(&Version::new(26, 1, Some(4)))); - assert!(!c.matches(&Version::new(26, 1, Some(2)))); -} - -#[test] -fn test_constraint_exactly_without_build_matches_prefix() { - let c = VersionConstraint::parse("26.1 exactly").unwrap(); - assert!(c.matches(&Version::new(26, 1, Some(0)))); - assert!(c.matches(&Version::new(26, 1, Some(5)))); - assert!(!c.matches(&Version::new(26, 2, Some(0)))); -} - -#[test] -fn test_constraint_between() { - let c = VersionConstraint::parse("between 25.12 and 26.2").unwrap(); - assert!(c.matches(&Version::new(26, 1, Some(0)))); - assert!(c.matches(&Version::new(25, 12, Some(0)))); - assert!(c.matches(&Version::new(26, 2, Some(0)))); - assert!(!c.matches(&Version::new(25, 11, Some(0)))); - assert!(!c.matches(&Version::new(26, 3, Some(0)))); -} - -#[test] -fn test_constraint_above() { - let c = VersionConstraint::parse("above 25.6").unwrap(); - assert!(c.matches(&Version::new(25, 7, Some(0)))); - assert!(c.matches(&Version::new(26, 1, Some(0)))); - assert!(!c.matches(&Version::new(25, 6, Some(0)))); - assert!(!c.matches(&Version::new(25, 5, Some(0)))); -} - -#[test] -fn test_constraint_below() { - let c = VersionConstraint::parse("below 27").unwrap(); - assert!(c.matches(&Version::new(26, 12, Some(99)))); - assert!(!c.matches(&Version::new(27, 1, Some(0)))); -} - -#[test] -fn test_constraint_above_below() { - let c = VersionConstraint::parse("26.1 or newer but below 27").unwrap(); - assert!(c.matches(&Version::new(26, 5, Some(0)))); - assert!(c.matches(&Version::new(26, 1, Some(0)))); - assert!(!c.matches(&Version::new(25, 12, Some(0)))); - assert!(!c.matches(&Version::new(27, 1, Some(0)))); -} - -#[test] -fn test_constraint_parse_rejects_garbage() { - assert!(VersionConstraint::parse("foo bar baz").is_err()); - assert!(VersionConstraint::parse("").is_err()); - assert!(VersionConstraint::parse("latest").is_err()); - assert!(VersionConstraint::parse(">=26.1").is_err()); -} - -#[test] -fn test_constraint_between_missing_and() { - let result = VersionConstraint::parse("between 25.12"); - assert!(result.is_err()); -} - -#[test] -fn test_constraint_display_roundtrip() { - let cases = vec![ - "any version", - "26.1 or newer", - "26.1.3 exactly", - "between 25.12 and 26.2", - "above 25.6", - "below 27.1", - "26.1 or newer but below 27.1", - ]; - for input in cases { - let c = VersionConstraint::parse(input).unwrap(); - let displayed = c.to_string(); - let reparsed = VersionConstraint::parse(&displayed).unwrap(); - assert_eq!( - c, reparsed, - "roundtrip failed for '{}' -> '{}' -> {:?}", - input, displayed, reparsed - ); - } -} - -#[test] -fn test_constraint_parse_trims_whitespace() { - let c = VersionConstraint::parse(" any version ").unwrap(); - assert_eq!(c, VersionConstraint::AnyVersion); -} - -// =========================================================================== -// Lockfile parser edge cases -// =========================================================================== - -#[test] -fn test_lockfile_parse_valid() { - let content = "\ -// Auto-generated by WFL. -package http-client - version is 26.1.3 - checksum is wflhash:a3f8b2c9d4e5f6a7 - -package json-parser - version is 25.12.8 - checksum is wflhash:b4c5d6e7f8a9b0c1 - requires text-utils 25.11.2 -"; - let lock = wflpkg::lockfile::parser::parse_lock_file(content).unwrap(); - assert_eq!(lock.packages.len(), 2); - assert_eq!(lock.packages[0].name, "http-client"); - assert_eq!(lock.packages[0].version.to_string(), "26.1.3"); - assert_eq!(lock.packages[1].dependencies.len(), 1); - assert_eq!(lock.packages[1].dependencies[0].name, "text-utils"); -} - -#[test] -fn test_lockfile_parse_empty() { - let lock = wflpkg::lockfile::parser::parse_lock_file("").unwrap(); - assert!(lock.packages.is_empty()); -} - -#[test] -fn test_lockfile_parse_comments_only() { - let content = "// just comments\n// nothing else\n"; - let lock = wflpkg::lockfile::parser::parse_lock_file(content).unwrap(); - assert!(lock.packages.is_empty()); -} - -#[test] -fn test_lockfile_parse_rejects_unrecognized_field() { - let content = "\ -package my-pkg - version is 26.1.0 - unknown_field is something -"; - let result = wflpkg::lockfile::parser::parse_lock_file(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Unrecognized field"), - "expected 'Unrecognized field', got: {msg}" - ); -} - -#[test] -fn test_lockfile_parse_rejects_indented_without_package() { - let content = " version is 26.1.0\n"; - let result = wflpkg::lockfile::parser::parse_lock_file(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("without a preceding package"), - "expected 'without a preceding package', got: {msg}" - ); -} - -#[test] -fn test_lockfile_parse_rejects_malformed_requires() { - let content = "\ -package my-pkg - version is 26.1.0 - requires only-name -"; - let result = wflpkg::lockfile::parser::parse_lock_file(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Malformed requires"), - "expected 'Malformed requires', got: {msg}" - ); -} - -#[test] -fn test_lockfile_parse_rejects_invalid_version() { - let content = "\ -package my-pkg - version is not-a-version -"; - let result = wflpkg::lockfile::parser::parse_lock_file(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Invalid version"), - "expected 'Invalid version', got: {msg}" - ); -} - -#[test] -fn test_lockfile_parse_rejects_unexpected_line() { - let content = "this is not valid\n"; - let result = wflpkg::lockfile::parser::parse_lock_file(content); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("Unexpected line"), - "expected 'Unexpected line', got: {msg}" - ); -} - -// =========================================================================== -// Manifest loading edge cases -// =========================================================================== - -#[test] -fn test_manifest_load_missing_file() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::ProjectManifest::load(&temp.path().join("nonexistent.wfl")); - assert!(result.is_err()); -} - -#[test] -fn test_manifest_load_valid() { - let temp = TempDir::new().unwrap(); - let manifest_path = temp.path().join("project.wfl"); - std::fs::write( - &manifest_path, - "name is my-project\nversion is 26.1.0\ndescription is A test project", - ) - .unwrap(); - - let manifest = wflpkg::ProjectManifest::load(&manifest_path).unwrap(); - assert_eq!(manifest.name, "my-project"); - assert_eq!(manifest.version_string, "26.1.0"); - assert_eq!(manifest.description, "A test project"); -} - -#[test] -fn test_manifest_entry_point_default() { - let manifest = wflpkg::ProjectManifest::default(); - assert_eq!(manifest.entry_point(), "src/main.wfl"); -} - -#[test] -fn test_manifest_registry_url_default() { - let manifest = wflpkg::ProjectManifest::default(); - assert_eq!(manifest.registry_url(), "wflhub.org"); -} - -#[test] -fn test_manifest_find_dependency() { - let temp = TempDir::new().unwrap(); - let manifest_path = temp.path().join("project.wfl"); - std::fs::write( - &manifest_path, - "name is my-project\nversion is 26.1.0\ndescription is Test\nrequires json-parser 26.1 or newer", - ) - .unwrap(); - - let manifest = wflpkg::ProjectManifest::load(&manifest_path).unwrap(); - assert!(manifest.find_dependency("json-parser").is_some()); - assert!(manifest.find_dependency("nonexistent").is_none()); -} - -#[test] -fn test_manifest_add_and_remove_dependency() { - let mut manifest = wflpkg::ProjectManifest::default(); - manifest.name = "test".to_string(); - - let dep = wflpkg::manifest::Dependency { - name: "my-dep".to_string(), - constraint: VersionConstraint::AnyVersion, - dev_only: false, - }; - manifest.add_dependency(dep); - assert!(manifest.find_dependency("my-dep").is_some()); - assert_eq!(manifest.dependencies.len(), 1); - - assert!(manifest.remove_dependency("my-dep")); - assert!(manifest.find_dependency("my-dep").is_none()); - assert_eq!(manifest.dependencies.len(), 0); -} - -#[test] -fn test_manifest_remove_nonexistent_dependency() { - let mut manifest = wflpkg::ProjectManifest::default(); - assert!(!manifest.remove_dependency("nonexistent")); -} - -// =========================================================================== -// Error display formatting -// =========================================================================== - -#[test] -fn test_error_display_manifest_not_found() { - let err = PackageError::ManifestNotFound("/some/path".to_string()); - let msg = err.to_string(); - assert!(msg.contains("/some/path")); - assert!(msg.contains("project.wfl")); -} - -#[test] -fn test_error_display_invalid_version() { - let err = PackageError::InvalidVersion("abc".to_string()); - let msg = err.to_string(); - assert!(msg.contains("abc")); - assert!(msg.contains("YY.MM.BUILD")); -} - -#[test] -fn test_error_display_invalid_version_constraint() { - let err = PackageError::InvalidVersionConstraint("garbage".to_string()); - let msg = err.to_string(); - assert!(msg.contains("garbage")); -} - -#[test] -fn test_error_display_not_authenticated() { - let err = PackageError::NotAuthenticated; - let msg = err.to_string(); - assert!(msg.contains("not logged in")); - assert!(msg.contains("wfl login")); -} - -#[test] -fn test_error_display_package_not_found_with_suggestions() { - let err = PackageError::PackageNotFound { - name: "jso-parser".to_string(), - suggestions: vec!["json-parser".to_string(), "json-reader".to_string()], - }; - let msg = err.to_string(); - assert!(msg.contains("jso-parser")); - assert!(msg.contains("json-parser")); - assert!(msg.contains("json-reader")); - assert!(msg.contains("Did you mean")); -} - -#[test] -fn test_error_display_package_not_found_without_suggestions() { - let err = PackageError::PackageNotFound { - name: "nonexistent".to_string(), - suggestions: Vec::new(), - }; - let msg = err.to_string(); - assert!(msg.contains("nonexistent")); - assert!(!msg.contains("Did you mean")); -} - -#[test] -fn test_error_display_checksum_mismatch() { - let err = PackageError::ChecksumMismatch { - package: "my-pkg".to_string(), - expected: "wflhash:aaa".to_string(), - actual: "wflhash:bbb".to_string(), - }; - let msg = err.to_string(); - assert!(msg.contains("my-pkg")); - assert!(msg.contains("wflhash:aaa")); - assert!(msg.contains("wflhash:bbb")); -} - -#[test] -fn test_error_display_version_conflict() { - let err = PackageError::VersionConflict { - package: "shared-dep".to_string(), - constraint_a: "26.1 or newer".to_string(), - source_a: "pkg-a".to_string(), - constraint_b: "below 26".to_string(), - source_b: "pkg-b".to_string(), - }; - let msg = err.to_string(); - assert!(msg.contains("shared-dep")); - assert!(msg.contains("pkg-a")); - assert!(msg.contains("pkg-b")); -} - -#[test] -fn test_error_display_lockfile_parse_error() { - let err = PackageError::LockFileParseError { - line: 42, - message: "unexpected token".to_string(), - }; - let msg = err.to_string(); - assert!(msg.contains("42")); - assert!(msg.contains("unexpected token")); -} - -#[test] -fn test_error_display_workspace_error() { - let err = PackageError::WorkspaceError("missing member".to_string()); - let msg = err.to_string(); - assert!(msg.contains("missing member")); -} - -#[test] -fn test_error_display_registry_unreachable() { - let err = PackageError::RegistryUnreachable("https://wflhub.org".to_string()); - let msg = err.to_string(); - assert!(msg.contains("wflhub.org")); - assert!(msg.contains("network")); -} - -#[test] -fn test_error_display_permission_required() { - let err = PackageError::PermissionRequired { - package: "my-pkg".to_string(), - permissions: vec!["file-access".to_string(), "network-access".to_string()], - }; - let msg = err.to_string(); - assert!(msg.contains("my-pkg")); - assert!(msg.contains("file-access")); - assert!(msg.contains("network-access")); -} - -#[test] -fn test_error_display_security_advisory() { - let err = PackageError::SecurityAdvisory { - package: "vuln-pkg".to_string(), - severity: "high".to_string(), - description: "Remote code execution".to_string(), - fixed_in: Some("26.2.0".to_string()), - }; - let msg = err.to_string(); - assert!(msg.contains("vuln-pkg")); - assert!(msg.contains("high")); - assert!(msg.contains("Remote code execution")); - assert!(msg.contains("26.2.0")); -} - -#[test] -fn test_error_from_io_error() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); - let pkg_err: PackageError = io_err.into(); - let msg = pkg_err.to_string(); - assert!(msg.contains("file not found")); -} diff --git a/crates/wflpkg/tests/workflow_integration.rs b/crates/wflpkg/tests/workflow_integration.rs deleted file mode 100644 index fa731fec..00000000 --- a/crates/wflpkg/tests/workflow_integration.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Integration tests for the end-to-end package workflow: -//! create → add → remove → update. - -use std::fs; -use tempfile::TempDir; - -// --------------------------------------------------------------------------- -// Helper: create a project and return (TempDir, project_dir PathBuf) -// --------------------------------------------------------------------------- -fn create_test_project(name: &str) -> (TempDir, std::path::PathBuf) { - let temp = TempDir::new().unwrap(); - let project_dir = wflpkg::commands::create::create_project(Some(name), temp.path()).unwrap(); - (temp, project_dir) -} - -// =========================================================================== -// create_project tests -// =========================================================================== - -#[test] -fn test_create_project_produces_expected_files() { - let (_temp, project_dir) = create_test_project("my-test-app"); - assert!(project_dir.join("project.wfl").exists()); - assert!(project_dir.join("src/main.wfl").exists()); - assert!(project_dir.join(".wflcfg").exists()); - assert!(project_dir.join(".gitignore").exists()); -} - -#[test] -fn test_create_project_manifest_has_correct_fields() { - let (_temp, project_dir) = create_test_project("my-test-app"); - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert_eq!(manifest.name, "my-test-app"); - assert!(!manifest.version_string.is_empty()); - assert_eq!(manifest.description, "A new WFL project"); -} - -#[test] -fn test_create_project_src_main_contains_hello() { - let (_temp, project_dir) = create_test_project("my-test-app"); - let main_content = fs::read_to_string(project_dir.join("src/main.wfl")).unwrap(); - assert!( - main_content.contains("display") || main_content.contains("Hello"), - "main.wfl should contain a display/hello statement: {main_content}" - ); -} - -#[test] -fn test_create_project_invalid_name_fails() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::commands::create::create_project(Some("BAD-NAME"), temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not valid"), - "expected InvalidPackageName: {msg}" - ); -} - -// =========================================================================== -// add_dependency tests -// =========================================================================== - -#[test] -fn test_add_dependency_updates_manifest() { - let (_temp, project_dir) = create_test_project("add-test"); - let args: Vec = vec!["http-client".to_string()]; - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert!( - manifest.find_dependency("http-client").is_some(), - "http-client should be in dependencies" - ); -} - -#[test] -fn test_add_dependency_with_version_constraint() { - let (_temp, project_dir) = create_test_project("add-ver-test"); - let args: Vec = "json-parser 26.1 or newer" - .split_whitespace() - .map(String::from) - .collect(); - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - let dep = manifest.find_dependency("json-parser").unwrap(); - assert_eq!( - dep.constraint, - wflpkg::VersionConstraint::OrNewer(wflpkg::Version::new(26, 1, None)) - ); -} - -#[test] -fn test_add_dependency_dev_flag() { - let (_temp, project_dir) = create_test_project("add-dev-test"); - let args: Vec = "test-runner 26.1 or newer for development" - .split_whitespace() - .map(String::from) - .collect(); - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - let dep = manifest.find_dependency("test-runner").unwrap(); - assert!(dep.dev_only, "test-runner should be a dev dependency"); -} - -#[test] -fn test_add_dependency_no_manifest_fails() { - let temp = TempDir::new().unwrap(); - let args: Vec = vec!["http-client".to_string()]; - let result = wflpkg::commands::add::add_dependency(&args, temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("could not find a project.wfl"), - "expected ManifestNotFound: {msg}" - ); -} - -// =========================================================================== -// remove_dependency tests -// =========================================================================== - -#[test] -fn test_remove_dependency_updates_manifest() { - let (_temp, project_dir) = create_test_project("remove-test"); - // Add first - let args: Vec = vec!["http-client".to_string()]; - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - // Remove - wflpkg::commands::remove::remove_dependency("http-client", &project_dir).unwrap(); - - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert!( - manifest.find_dependency("http-client").is_none(), - "http-client should be removed" - ); -} - -#[test] -fn test_remove_dependency_cleans_packages_dir() { - let (_temp, project_dir) = create_test_project("remove-clean-test"); - // Add dependency so it is in the manifest - let args: Vec = vec!["http-client".to_string()]; - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - // Simulate an installed package directory - let pkg_dir = project_dir.join("packages").join("http-client"); - fs::create_dir_all(&pkg_dir).unwrap(); - fs::write(pkg_dir.join("lib.wfl"), "// stub").unwrap(); - - wflpkg::commands::remove::remove_dependency("http-client", &project_dir).unwrap(); - assert!(!pkg_dir.exists(), "packages/http-client should be cleaned"); -} - -#[test] -fn test_remove_dependency_rejects_invalid_name() { - let (_temp, project_dir) = create_test_project("remove-name-test"); - let result = wflpkg::commands::remove::remove_dependency("../outside", &project_dir); - assert!(result.is_err()); - assert!( - result.unwrap_err().to_string().contains("not valid"), - "path-like dependency names must be rejected before path construction" - ); -} - -#[cfg(unix)] -#[test] -fn test_remove_dependency_rejects_symlinked_packages_root() { - use std::os::unix::fs::symlink; - - let (temp, project_dir) = create_test_project("remove-root-link-test"); - wflpkg::commands::add::add_dependency(&["http-client".to_string()], &project_dir).unwrap(); - - let outside = temp.path().join("outside-packages"); - let outside_package = outside.join("http-client"); - fs::create_dir_all(&outside_package).unwrap(); - let sentinel = outside_package.join("sentinel.txt"); - fs::write(&sentinel, "must survive").unwrap(); - symlink(&outside, project_dir.join("packages")).unwrap(); - - let result = wflpkg::commands::remove::remove_dependency("http-client", &project_dir); - assert!( - result.is_err(), - "a symlinked packages root must be rejected" - ); - assert!( - result.unwrap_err().to_string().contains("symbolic link"), - "the error should explain why recursive removal was refused" - ); - assert!(sentinel.exists(), "outside package content must survive"); - - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert!( - manifest.find_dependency("http-client").is_some(), - "a refused removal must not change the manifest" - ); -} - -#[cfg(unix)] -#[test] -fn test_remove_dependency_rejects_symlinked_package_target() { - use std::os::unix::fs::symlink; - - let (temp, project_dir) = create_test_project("remove-target-link-test"); - wflpkg::commands::add::add_dependency(&["http-client".to_string()], &project_dir).unwrap(); - - let outside = temp.path().join("outside-package"); - fs::create_dir_all(&outside).unwrap(); - let sentinel = outside.join("sentinel.txt"); - fs::write(&sentinel, "must survive").unwrap(); - fs::create_dir_all(project_dir.join("packages")).unwrap(); - symlink(&outside, project_dir.join("packages/http-client")).unwrap(); - - let result = wflpkg::commands::remove::remove_dependency("http-client", &project_dir); - assert!( - result.is_err(), - "a symlinked package target must be rejected" - ); - assert!( - result.unwrap_err().to_string().contains("symbolic link"), - "the error should explain why recursive removal was refused" - ); - assert!(sentinel.exists(), "outside package content must survive"); -} - -#[cfg(unix)] -#[test] -fn test_remove_dependency_rejects_symlinked_project_manifest() { - use std::os::unix::fs::symlink; - - let (temp, project_dir) = create_test_project("remove-manifest-link-test"); - wflpkg::commands::add::add_dependency(&["http-client".to_string()], &project_dir).unwrap(); - - let project_manifest = project_dir.join("project.wfl"); - let outside_manifest = temp.path().join("outside-project.wfl"); - let original = fs::read_to_string(&project_manifest).unwrap(); - fs::write(&outside_manifest, &original).unwrap(); - fs::remove_file(&project_manifest).unwrap(); - symlink(&outside_manifest, &project_manifest).unwrap(); - - let result = wflpkg::commands::remove::remove_dependency("http-client", &project_dir); - assert!( - result.is_err(), - "a symlinked project manifest must be rejected" - ); - assert!( - result.unwrap_err().to_string().contains("symbolic link"), - "the error should identify the unsafe manifest" - ); - assert_eq!( - fs::read_to_string(&outside_manifest).unwrap(), - original, - "a refused removal must not rewrite an outside manifest" - ); -} - -#[test] -fn test_remove_dependency_not_found() { - let (_temp, project_dir) = create_test_project("remove-nf-test"); - let result = wflpkg::commands::remove::remove_dependency("nonexistent", &project_dir); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not listed"), - "expected 'not listed', got: {msg}" - ); -} - -#[test] -fn test_remove_dependency_no_manifest_fails() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::commands::remove::remove_dependency("http-client", temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("could not find a project.wfl"), - "expected ManifestNotFound: {msg}" - ); -} - -// =========================================================================== -// update_dependencies tests -// =========================================================================== - -#[test] -fn test_update_all_not_implemented() { - let (_temp, project_dir) = create_test_project("update-all-test"); - // Add a dependency so there's something to update - let args: Vec = vec!["http-client".to_string()]; - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - - let result = wflpkg::commands::update::update_dependencies(None, &project_dir); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not yet implemented"), - "expected 'not yet implemented', got: {msg}" - ); -} - -#[test] -fn test_update_specific_not_implemented() { - let (_temp, project_dir) = create_test_project("update-spec-test"); - let args: Vec = vec!["http-client".to_string()]; - wflpkg::commands::add::add_dependency(&args, &project_dir).unwrap(); - - let result = wflpkg::commands::update::update_dependencies(Some("http-client"), &project_dir); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not yet implemented"), - "expected 'not yet implemented', got: {msg}" - ); -} - -#[test] -fn test_update_unknown_package() { - let (_temp, project_dir) = create_test_project("update-unk-test"); - let result = wflpkg::commands::update::update_dependencies(Some("nonexistent"), &project_dir); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("not listed"), - "expected 'not listed', got: {msg}" - ); -} - -#[test] -fn test_update_no_manifest_fails() { - let temp = TempDir::new().unwrap(); - let result = wflpkg::commands::update::update_dependencies(None, temp.path()); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("could not find a project.wfl"), - "expected ManifestNotFound: {msg}" - ); -} - -// =========================================================================== -// Full roundtrip -// =========================================================================== - -#[test] -fn test_full_roundtrip_create_add_remove() { - let (_temp, project_dir) = create_test_project("roundtrip-test"); - - // Add two dependencies - let args1: Vec = vec!["http-client".to_string()]; - wflpkg::commands::add::add_dependency(&args1, &project_dir).unwrap(); - - let args2: Vec = "json-parser any version" - .split_whitespace() - .map(String::from) - .collect(); - wflpkg::commands::add::add_dependency(&args2, &project_dir).unwrap(); - - // Verify both exist - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert_eq!(manifest.dependencies.len(), 2); - - // Remove one - wflpkg::commands::remove::remove_dependency("http-client", &project_dir).unwrap(); - - // Verify final state - let manifest = wflpkg::ProjectManifest::load(&project_dir.join("project.wfl")).unwrap(); - assert_eq!(manifest.dependencies.len(), 1); - assert!(manifest.find_dependency("json-parser").is_some()); - assert!(manifest.find_dependency("http-client").is_none()); -} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b5a75b7b..cd6757ac 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -171,16 +165,6 @@ dependencies = [ "cipher 0.5.2", ] -[[package]] -name = "bstr" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" -dependencies = [ - "memchr", - "serde_core", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -380,34 +364,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -575,38 +531,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "flume" version = "0.12.0" @@ -771,19 +701,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - [[package]] name = "h2" version = "0.3.27" @@ -1217,22 +1134,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1385,12 +1286,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1482,16 +1377,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.2" @@ -1933,7 +1818,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -1970,27 +1854,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rpassword" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", -] - -[[package]] -name = "rtoolbox" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -2006,19 +1869,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.22.4" @@ -2357,12 +2207,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2686,30 +2530,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -3310,7 +3130,6 @@ dependencies = [ "tokio", "uuid", "warp", - "wflpkg", "zeroize", ] @@ -3322,26 +3141,6 @@ dependencies = [ "wfl", ] -[[package]] -name = "wflpkg" -version = "0.1.0" -dependencies = [ - "chrono", - "flate2", - "ignore", - "libc", - "reqwest", - "rpassword", - "rustyline", - "serde", - "serde_json", - "sha2 0.10.9", - "tar", - "tempfile", - "tokio", - "zeroize", -] - [[package]] name = "whoami" version = "2.1.2" @@ -3436,15 +3235,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3524,16 +3314,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/scripts/test_docs_code_blocks.py b/scripts/test_docs_code_blocks.py index d54c5175..29217d18 100644 --- a/scripts/test_docs_code_blocks.py +++ b/scripts/test_docs_code_blocks.py @@ -368,8 +368,7 @@ def categorize(blk: Block) -> str: return "LANG_GAP" if re.search(r"repeat\s+\d+\s+times", code): return "LANG_GAP" - if any(s in se for s in ("Cannot resolve module path", "no project.wfl", - "Cannot resolve package")): + if "Cannot resolve module path" in se: return "NEEDS_MODULE" if "test mode" in se: return "NEEDS_TESTMODE" diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 2f38070d..535c8de0 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -4280,19 +4280,6 @@ impl Interpreter { line: usize, column: usize, ) -> Result { - // Handle package: protocol for package manager imports - if let Some(package_name) = relative_path.strip_prefix("package:") { - let package_name = package_name.trim(); - if package_name.is_empty() { - return Err(RuntimeError::new( - "Invalid import: \"package:\" requires a package name (e.g. \"package:my-lib\")".to_string(), - line, - column, - )); - } - return self.resolve_package_path(package_name, line, column).await; - } - // Extract and clone the Option to avoid holding the borrow across await let opt_path = self.current_source_file.borrow().as_ref().cloned(); @@ -4328,106 +4315,6 @@ impl Interpreter { Ok(canonical) } - /// Resolve a `package:` protocol path to the package's entry point file. - async fn resolve_package_path( - &self, - package_name: &str, - line: usize, - column: usize, - ) -> Result { - // Validate package name: reject empty, path separators, and traversal segments. - if package_name.is_empty() { - return Err(RuntimeError::new( - "Invalid package name: name cannot be empty.".to_string(), - line, - column, - )); - } - if package_name.contains('/') || package_name.contains('\\') || package_name.contains("..") - { - return Err(RuntimeError::new( - format!( - "Invalid package name \"{}\": package names must not contain \ - path separators ('/', '\\') or traversal segments ('..').", - package_name - ), - line, - column, - )); - } - - // Find the project root by looking for project.wfl - let project_dir = self.find_project_root().ok_or_else(|| { - RuntimeError::new( - format!( - "Cannot resolve package \"{}\" — no project.wfl found.\n\ - \nTo use packages, your project needs a project.wfl manifest.\n\ - Run: wfl create project", - package_name - ), - line, - column, - ) - })?; - - let entry = - wflpkg::resolver::package_path::resolve_package_entry(package_name, &project_dir) - .map_err(|e| RuntimeError::new(e.to_string(), line, column))?; - - // Canonicalize the resolved path - let canonical = tokio::fs::canonicalize(&entry).await.map_err(|e| { - RuntimeError::new( - format!( - "Cannot resolve package entry point for \"{}\": {}\n\ - \nRun: wfl add {}", - package_name, e, package_name - ), - line, - column, - ) - })?; - - // Verify the resolved entry is within the packages root to prevent traversal. - let packages_root = project_dir.join("packages"); - if let Ok(canon_root) = tokio::fs::canonicalize(&packages_root).await - && !canonical.starts_with(&canon_root) - { - return Err(RuntimeError::new( - format!( - "Package \"{}\" resolved to a path outside the packages directory. \ - This may indicate a path traversal attempt.", - package_name - ), - line, - column, - )); - } - - Ok(canonical) - } - - /// Find the project root directory by walking up from the current source file - /// looking for a `project.wfl` manifest. - fn find_project_root(&self) -> Option { - let source = self.current_source_file.borrow().clone(); - let start_dir = if let Some(ref path) = source { - path.parent().map(|p| p.to_path_buf()) - } else { - std::env::current_dir().ok() - }; - - let mut dir = start_dir?; - loop { - if dir.join("project.wfl").exists() { - return Some(dir); - } - if !dir.pop() { - break; - } - } - None - } - fn check_circular_dependency( &self, path: &PathBuf, diff --git a/src/main.rs b/src/main.rs index 814b8d97..4e45c22f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,8 +18,6 @@ use wfl::typechecker::{TypeCheckError, TypeChecker}; use wfl::wfl_config; use wfl::{error, exec_trace, info}; -const DEFAULT_REGISTRY: &str = "wflhub.org"; - fn print_help() { println!("WebFirst Language (WFL) Compiler and Interpreter"); println!(); @@ -56,42 +54,9 @@ fn print_help() { println!(" This ensures that scripts are validated for semantic correctness"); println!(" and type safety before execution, preventing many common runtime errors."); println!(); - println!("PACKAGE MANAGEMENT:"); - println!(" create [project] [called ] Create a new WFL project"); - println!(" add [constraint] Add a dependency"); - println!(" remove Remove a dependency"); - println!(" update [package] Update dependencies"); - println!(" build Build the project"); - println!(" run Run the project entry point"); - println!(" share Publish to the registry"); - println!(" search Search for packages"); - println!(" info Show package details"); - println!(" login [registry] / logout Registry authentication"); - println!(" check security Audit for vulnerabilities"); - println!(" check compatibility Check API compatibility"); - println!(); println!("If no file is specified, the REPL will be started."); } -/// Parse "create project called " args for the package manager. -fn parse_create_project_args(args: &[String]) -> Option { - // Skip "project" keyword if present - let args = if !args.is_empty() && args[0] == "project" { - &args[1..] - } else { - args - }; - - // Look for "called " - if args.len() >= 2 && args[0] == "called" { - Some(args[1].clone()) - } else if args.len() == 1 && args[0] != "called" { - Some(args[0].clone()) - } else { - None - } -} - /// Stack size for the thread that runs the interpreter. /// fn build_runtime() -> io::Result { @@ -175,7 +140,7 @@ async fn run() -> io::Result<()> { #[cfg(feature = "dhat-ad-hoc")] let _profiler = dhat::Profiler::new_ad_hoc(); - let mut args: Vec = env::args().collect(); + let args: Vec = env::args().collect(); if args.len() == 1 { if let Err(e) = repl::run_repl().await { @@ -191,182 +156,6 @@ async fn run() -> io::Result<()> { return Ok(()); } - // Package manager subcommands (positional, not flags) - if args.len() >= 2 && !args[1].starts_with('-') { - let subcommand = args[1].as_str(); - let sub_args: Vec = args[2..].to_vec(); - let cwd = std::env::current_dir()?; - - match subcommand { - "create" => { - // Parse "create project called " or "create project" - let name = parse_create_project_args(&sub_args); - match wflpkg::commands::create::create_project(name.as_deref(), &cwd) { - Ok(_) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - "add" => match wflpkg::commands::add::add_dependency(&sub_args, &cwd) { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - }, - "remove" => { - if sub_args.is_empty() { - eprintln!("Usage: wfl remove "); - process::exit(2); - } - match wflpkg::commands::remove::remove_dependency(&sub_args[0], &cwd) { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - "update" => { - let pkg = sub_args.first().map(|s| s.as_str()); - match wflpkg::commands::update::update_dependencies(pkg, &cwd) { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - "build" => match wflpkg::commands::build::build_project(&cwd).await { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - }, - "run" => { - if sub_args.iter().any(|a| a.ends_with(".wfl")) { - // "wfl run file.wfl" — strip "run" so normal file execution handles it - args.remove(1); - } else { - // "wfl run" (no file arg) — package run command - match wflpkg::commands::run::run_project(&cwd).await { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - } - "share" => match wflpkg::commands::share::share_package(&cwd).await { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - }, - "search" => { - if sub_args.is_empty() { - eprintln!("Usage: wfl search "); - process::exit(2); - } - match wflpkg::commands::search::search_packages(&sub_args[0], DEFAULT_REGISTRY) - .await - { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - "info" => { - if sub_args.is_empty() { - eprintln!("Usage: wfl info "); - process::exit(2); - } - match wflpkg::commands::info::show_package_info(&sub_args[0], DEFAULT_REGISTRY) - .await - { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - "login" => match wflpkg::commands::login::login( - sub_args - .first() - .map(String::as_str) - .unwrap_or(DEFAULT_REGISTRY), - ) { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - }, - "logout" => match wflpkg::commands::login::logout() { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - }, - "check" => { - if sub_args.is_empty() { - eprintln!("Usage: wfl check "); - process::exit(2); - } - match sub_args[0].as_str() { - "security" => match wflpkg::commands::check::check_security(&cwd).await { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - }, - "compatibility" => { - match wflpkg::commands::check::check_compatibility(&cwd).await { - Ok(()) => return Ok(()), - Err(e) => { - eprintln!("{}", e); - process::exit(1); - } - } - } - other => { - eprintln!( - "Unknown check type: \"{}\"\n\nValid options:\n wfl check security\n wfl check compatibility", - other - ); - process::exit(2); - } - } - } - "test" => { - if sub_args.iter().any(|a| a.ends_with(".wfl")) { - // "wfl test file.wfl" — strip "test" and inject "--test" for normal handling - args.remove(1); - args.insert(1, "--test".to_string()); - } else { - // "wfl test" without a .wfl file — package test command - // TODO: Run test files from project - println!("Package test mode not yet implemented."); - println!("Use 'wfl --test ' to run tests on a specific file."); - return Ok(()); - } - } - _ => { - // Fall through to existing flag/file parsing - } - } - } - // Check for version flag only in WFL flags (before script filename) // This check is moved into the main argument parsing loop below