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 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}" + ); +}