diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03ecb3d..7d692bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: gh release create "$RELEASE_TAG" \ --verify-tag \ --draft \ - --generate-notes \ + --notes-file RELEASE_NOTES.md \ --title "Vex $RELEASE_TAG" \ dist/vex-*.tar.gz \ dist/vex-*.zip \ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ed0417a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to Vex are documented in this file. Vex follows Semantic +Versioning while the public command and lockfile contracts are still maturing. + +## [0.0.1] - 2026-08-23 + +### Added + +- Manifest-based `init`, `build`, `run`, `check`, `fetch`, `update`, `info`, and + `setup wavec` commands for Wave projects. +- Recursive Git and path dependency resolution with cycle, package-name, + source, and version conflict detection. +- `vex.lock` schema v2, which records exact Git object IDs and dependency graph + edges so a manifest and lockfile reproduce the same dependency graph. +- Full and package-targeted Git updates. `vex update ...` preserves + unrelated locked commits and accepts directly or transitively referenced + package names. +- `--locked` and `--offline` dependency modes, including their combined use. +- Cargo-style progress reporting and actionable dependency errors. +- `wavec` discovery through `PATH`, an explicit `VEX_WAVEC` override, and + validation of the compiler dry-run JSON schema v1 contract. +- Reproducible release archives for Linux amd64/arm64/RISC-V, Windows x64, and + macOS Intel/Apple Silicon, with SHA-256 checksums and GitHub provenance + attestations. + +### Fixed and hardened + +- Path dependency locations are stored relative to the project when possible, + so the same relative package tree and lockfile can move together. +- Help is read-only and succeeds without a manifest; invalid `init`, `info`, + target, and global command arguments now fail consistently. +- Git object IDs loaded from a lockfile must be complete hexadecimal IDs. +- Git clone and revision commands terminate option parsing, Git's external + transport protocol is disabled for managed operations, and symbolic links + cannot redirect the managed `.vex/deps` area outside the project. +- The downloaded `wavec` installer uses exclusive, uniquely named temporary + files and removes them after execution. +- Release archives carry a lockfile-checked inventory of third-party licenses + and copyright notices. + +### Compatibility notes + +- Lockfiles produced by development snapshots may contain absolute path + dependency locations. Run `vex fetch` once to rewrite those entries before + using `--locked` with v0.0.1. +- Vex has been tested with `wavec 0.2.0-pre-beta`. The authoritative compiler + compatibility check is support for `build --dry-run --error-format=json` + schema version 1. +- A central registry, publishing, workspaces, `vex add`/`vex remove`, and a + global Git cache are not part of v0.0.1. + +[0.0.1]: https://github.com/wavefnd/Vex/releases/tag/v0.0.1 diff --git a/Cargo.toml b/Cargo.toml index 01a3ab6..0512ece 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,13 @@ name = "vex" version = "0.0.1" edition = "2021" +description = "Package manager and build tool for the Wave programming language" +license = "MPL-2.0" +repository = "https://github.com/wavefnd/Vex" +readme = "README.md" +keywords = ["wave", "package-manager", "build-tool"] +categories = ["command-line-utilities", "development-tools::build-utils"] +publish = false [dependencies] wson_rs = "0.2.5" diff --git a/NOTICE b/NOTICE index 489a6b2..30dc9de 100644 --- a/NOTICE +++ b/NOTICE @@ -13,7 +13,8 @@ copyright information. Vex invokes the separately distributed `wavec` compiler. The Vex source and release archives do not grant additional rights to `wavec`, Wave packages, or third-party dependencies; those works remain subject to their respective -licenses. +licenses. The locked dependency inventory, copyright attributions, and +applicable license notices are provided in THIRD_PARTY_LICENSES.md. Source code for this release is available from: https://github.com/wavefnd/Vex diff --git a/README.md b/README.md index 0850b5d..d1293a1 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,15 @@ Vex is designed to sit above `wavec` in the same way Cargo sits above `rustc`: V ## Requirements -- Rust toolchain for building Vex from source - `wavec` compatible with the `build --dry-run --error-format=json` schema v1 contract - `git` when using Git dependencies +- Rust toolchain only when building Vex from source - Python 3.11 or newer when using the release tooling Vex runs `wavec` from `PATH` by default. Set `VEX_WAVEC=/path/to/wavec` to use a specific compiler binary. +Vex v0.0.1 is tested with `wavec 0.2.0-pre-beta`; support for schema v1 is the +authoritative compatibility requirement. Vex reports a schema mismatch before +the real build and suggests selecting another compiler with `VEX_WAVEC`. ## Platform validation @@ -31,8 +34,51 @@ the release workflow also passes package and clean-environment smoke tests. Windows release artifacts use the MSVC target. A Windows GNU artifact is not part of the v0.0.1 scope. RISC-V remains experimental because its test coverage is limited to cross-build and QEMU smoke rather than the complete integration -suite. Final minimum OS and glibc versions will be fixed by the release workflow -before v0.0.1 is tagged. +suite. + +The v0.0.1 Linux GNU archives are built on Ubuntu 24.04 and require a glibc-based +system; Ubuntu 24.04 is the supported runtime baseline. Windows artifacts are +validated on the GitHub Windows Server 2025 runner, and macOS artifacts on +macOS 15. Older operating systems and other distributions are best effort for +this first release. + +## Install + +Download the archive and `SHA256SUMS` for your platform from the +[GitHub release](https://github.com/wavefnd/Vex/releases/tag/v0.0.1). Verify the +download before extracting it: + +```sh +sha256sum --check SHA256SUMS +tar -xzf vex-v0.0.1-x86_64-unknown-linux-gnu.tar.gz +install -m 0755 vex-v0.0.1-x86_64-unknown-linux-gnu/vex ~/.local/bin/vex +vex --version +``` + +On Windows, compare `Get-FileHash -Algorithm SHA256` with the matching +line in `SHA256SUMS`, extract the zip, and place `vex.exe` in a directory on +`PATH`. GitHub CLI can additionally verify the published provenance: + +```sh +gh attestation verify vex-v0.0.1-x86_64-unknown-linux-gnu.tar.gz \ + --repo wavefnd/Vex +``` + +To build from source instead: + +```sh +git clone https://github.com/wavefnd/Vex.git +cd Vex +cargo build --locked --release +install -m 0755 target/release/vex ~/.local/bin/vex +``` + +Install `wavec` separately and make it available on `PATH`, or set +`VEX_WAVEC` to its full path. `vex setup wavec` is an explicit convenience +command that downloads and executes the official installer from +`wave-lang.dev`; review that trust and network boundary before using it. A +specific compiler can be requested with `vex setup wavec --version +0.2.0-pre-beta`. ## Commands @@ -179,10 +225,11 @@ python3 x.py package x86_64-unknown-linux-gnu python3 x.py checksum x86_64-unknown-linux-gnu ``` -Archives contain the Vex executable together with `README.md`, `LICENSE`, -`NOTICE`, and `COPYRIGHT`. Their file order, permissions, owners, and timestamps -are normalized. Set `SOURCE_DATE_EPOCH` to an explicit non-negative Unix -timestamp when reproducing an artifact outside the tagged source revision. +Archives contain the Vex executable together with `README.md`, `CHANGELOG.md`, +`LICENSE`, `NOTICE`, `COPYRIGHT`, and `THIRD_PARTY_LICENSES.md`. Their file +order, permissions, owners, and timestamps are normalized. Set +`SOURCE_DATE_EPOCH` to an explicit non-negative Unix timestamp when reproducing +an artifact outside the tagged source revision. `python3 x.py release [...]` is intentionally stricter than separate build and package commands. It runs the complete validation suite and succeeds @@ -222,6 +269,9 @@ gh attestation verify vex-v0.0.1-x86_64-unknown-linux-gnu.tar.gz \ - [Maintainers](MAINTAINERS) - [Security Policy](SECURITY.md) - [Release Process](RELEASING.md) +- [v0.0.1 Release Notes](RELEASE_NOTES.md) +- [Changelog](CHANGELOG.md) - [Copyright](COPYRIGHT) - [Notice](NOTICE) +- [Third-party licenses](THIRD_PARTY_LICENSES.md) - [AI Usage Policy](ai.txt) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..6d04d09 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,57 @@ +# Vex v0.0.1 + +Vex v0.0.1 is the first public release of the package manager and build tool +for the Wave programming language. It establishes the manifest, dependency, +lockfile, compiler, and release contracts that future versions will build on. + +## Highlights + +- Create and inspect `vex.ws` projects with `vex init` and `vex info`. +- Build, check, and run Wave packages while Vex manages the internal `wavec` + invocation. +- Resolve recursive Git and path dependencies and record the complete graph in + `vex.lock` schema v2. +- Reproduce exact Git commits with `--locked`, prohibit Git network access with + `--offline`, or combine both modes for strict CI builds. +- Refresh the whole Git graph with `vex update`, or update direct and transitive + packages selectively with `vex update ...` while preserving + unrelated commits. +- Download reproducible archives with SHA-256 checksums and GitHub build + provenance for Linux amd64/arm64/RISC-V, Windows x64, and macOS Intel/Apple + Silicon. + +## Compiler compatibility + +Vex v0.0.1 is tested with `wavec 0.2.0-pre-beta`. A compatible compiler must +support `wavec build --dry-run --error-format=json` schema version 1. Vex finds +`wavec` on `PATH` by default; use `VEX_WAVEC=/path/to/wavec` to select another +binary. + +## Verify before installing + +Download the archive for your target together with `SHA256SUMS`, then run: + +```sh +sha256sum --check SHA256SUMS +gh attestation verify --repo wavefnd/Vex +``` + +Linux GNU archives use Ubuntu 24.04 as their supported runtime baseline. +Windows x64 is validated on Windows Server 2025 and macOS archives on macOS 15. +The RISC-V archive is experimental and receives cross-build plus QEMU smoke +coverage rather than the complete native integration suite. + +## Known scope + +This release intentionally has no central registry, publishing command, +workspace support, `vex add`/`vex remove`, or global Git cache. Raw `wavec` +options are not accepted by Vex commands. + +Development lockfiles containing absolute path dependency locations should be +rewritten once with `vex fetch` before using `--locked` with v0.0.1. See the +[changelog](https://github.com/wavefnd/Vex/blob/v0.0.1/CHANGELOG.md) and +[installation guide](https://github.com/wavefnd/Vex/blob/v0.0.1/README.md#install) +for full details. + +Thank you to every contributor and tester who helped establish Vex's first +reproducible package-management and release baseline. diff --git a/RELEASING.md b/RELEASING.md index 8d21525..c207b40 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -22,17 +22,33 @@ GitHub Release. It never publishes a release automatically. Start from the current `wavefnd/Vex:master`. Complete the release-candidate checklist before tagging: -1. Update `CHANGELOG.md` and user-facing release notes. +1. Update `CHANGELOG.md` and the reviewed `RELEASE_NOTES.md` used by the release + workflow. 2. Confirm the supported platform table and compatible `wavec` contract. 3. Confirm that `Cargo.toml` contains the intended version and that `Cargo.lock` is committed. -4. Run the complete local validation suite: +4. Audit the locked Rust dependency graph for known vulnerabilities and review + every dependency license. Record the scanner, advisory database date, and + result in the pull request. For example, OSV-Scanner v2 can inspect the + committed lockfile with: + + ```sh + osv-scanner scan source --lockfile Cargo.lock + cargo metadata --locked --format-version 1 + ``` + +5. Run the complete local validation suite: ```sh python3 x.py check ``` -5. Merge the release-candidate pull request and wait for every required CI +6. Run a real product smoke with a compatible `wavec`: initialize a temporary + project, run Hello World through `PATH`, repeat a locked/offline build, and + confirm a raw compiler option such as `vex build --emit=obj` is rejected. + The integration suite must also cover path-lock relocation, Git lock + reproducibility, full and targeted updates, and compiler schema rejection. +7. Merge the release-candidate pull request and wait for every required CI check on `master` to pass. Do not create a release tag from a feature branch, a dirty checkout, or a @@ -95,7 +111,9 @@ gh attestation verify vex-v0.0.1-x86_64-unknown-linux-gnu.tar.gz \ Repeat attestation verification for every archive and `SHA256SUMS`. Extract at least one native archive in a clean environment and run `vex --version` and `vex --help`. Complete the documented Wave project smoke test with a compatible -`wavec` before publication. +`wavec` before publication. Confirm that each archive also contains +`README.md`, `CHANGELOG.md`, `LICENSE`, `NOTICE`, `COPYRIGHT`, and +`THIRD_PARTY_LICENSES.md`. ## 4. Publish deliberately diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..62e690b --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,85 @@ +# Third-party software notices + +Vex binaries include software from the Rust packages below. Versions are fixed +by `Cargo.lock`; links identify the corresponding source distributions and +their complete license files. This inventory was generated from Cargo metadata +for v0.0.1. + +| Packages | Version(s) | Declared license | +| --- | --- | --- | +| [aho-corasick](https://crates.io/crates/aho-corasick/1.1.4), [memchr](https://crates.io/crates/memchr/2.8.0) | 1.1.4, 2.8.0 | Unlicense OR MIT | +| [android_system_properties](https://crates.io/crates/android_system_properties/0.1.5) | 0.1.5 | MIT OR Apache-2.0 | +| [autocfg](https://crates.io/crates/autocfg/1.5.0), [bumpalo](https://crates.io/crates/bumpalo/3.20.2), [cc](https://crates.io/crates/cc/1.2.57), [cfg-if](https://crates.io/crates/cfg-if/1.0.4) | 1.5.0, 3.20.2, 1.2.57, 1.0.4 | MIT OR Apache-2.0 | +| [chrono](https://crates.io/crates/chrono/0.4.44), [core-foundation-sys](https://crates.io/crates/core-foundation-sys/0.8.7) | 0.4.44, 0.8.7 | MIT OR Apache-2.0 | +| [colorex](https://crates.io/crates/colorex/0.1.2), [wson_rs](https://crates.io/crates/wson_rs/0.2.5) | 0.1.2, 0.2.5 | MPL-2.0 | +| [find-msvc-tools](https://crates.io/crates/find-msvc-tools/0.1.9), [iana-time-zone](https://crates.io/crates/iana-time-zone/0.1.65), [iana-time-zone-haiku](https://crates.io/crates/iana-time-zone-haiku/0.1.2) | 0.1.9, 0.1.65, 0.1.2 | MIT OR Apache-2.0 | +| [itoa](https://crates.io/crates/itoa/1.0.18), [js-sys](https://crates.io/crates/js-sys/0.3.91), [libc](https://crates.io/crates/libc/0.2.183), [log](https://crates.io/crates/log/0.4.29) | 1.0.18, 0.3.91, 0.2.183, 0.4.29 | MIT OR Apache-2.0 | +| [num-traits](https://crates.io/crates/num-traits/0.2.19), [once_cell](https://crates.io/crates/once_cell/1.21.4), [proc-macro2](https://crates.io/crates/proc-macro2/1.0.106), [quote](https://crates.io/crates/quote/1.0.45) | 0.2.19, 1.21.4, 1.0.106, 1.0.45 | MIT OR Apache-2.0 | +| [regex](https://crates.io/crates/regex/1.12.3), [regex-automata](https://crates.io/crates/regex-automata/0.4.14), [regex-syntax](https://crates.io/crates/regex-syntax/0.8.10) | 1.12.3, 0.4.14, 0.8.10 | MIT OR Apache-2.0 | +| [rustversion](https://crates.io/crates/rustversion/1.0.22), [serde](https://crates.io/crates/serde/1.0.229), [serde_core](https://crates.io/crates/serde_core/1.0.229), [serde_derive](https://crates.io/crates/serde_derive/1.0.229) | 1.0.22, 1.0.229 | MIT OR Apache-2.0 | +| [serde_json](https://crates.io/crates/serde_json/1.0.151), [shlex](https://crates.io/crates/shlex/1.3.0), [syn](https://crates.io/crates/syn/2.0.117), [syn](https://crates.io/crates/syn/3.0.3) | 1.0.151, 1.3.0, 2.0.117, 3.0.3 | MIT OR Apache-2.0 | +| [unicode-ident](https://crates.io/crates/unicode-ident/1.0.24) | 1.0.24 | (MIT OR Apache-2.0) AND Unicode-3.0 | +| [wasm-bindgen](https://crates.io/crates/wasm-bindgen/0.2.114), [wasm-bindgen-macro](https://crates.io/crates/wasm-bindgen-macro/0.2.114), [wasm-bindgen-macro-support](https://crates.io/crates/wasm-bindgen-macro-support/0.2.114), [wasm-bindgen-shared](https://crates.io/crates/wasm-bindgen-shared/0.2.114) | 0.2.114 | MIT OR Apache-2.0 | +| [windows-core](https://crates.io/crates/windows-core/0.62.2), [windows-implement](https://crates.io/crates/windows-implement/0.60.2), [windows-interface](https://crates.io/crates/windows-interface/0.59.3) | 0.62.2, 0.60.2, 0.59.3 | MIT OR Apache-2.0 | +| [windows-link](https://crates.io/crates/windows-link/0.2.1), [windows-result](https://crates.io/crates/windows-result/0.4.1), [windows-strings](https://crates.io/crates/windows-strings/0.5.1) | 0.2.1, 0.4.1, 0.5.1 | MIT OR Apache-2.0 | +| [zmij](https://crates.io/crates/zmij/1.0.23) | 1.0.23 | MIT | + +Relevant copyright holders identified by those source distributions include +Andrew Gallant, Nicolas Silva, Josh Stone, Nick Fitzgerald, Alex Crichton, +Andrew D. Straw, Nicholas Allegra, the Mozilla Foundation, the Rust Project +Developers, Microsoft Corporation, and the packages' other authors and +contributors. + +For packages offered under a choice of MIT or Apache-2.0, this distribution +relies on the MIT option. The MIT notice is reproduced below. MPL-2.0 packages +are covered by the complete MPL-2.0 text in `LICENSE`; their exact source code +is available through the versioned links above. + +## MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Unicode License v3 + +Copyright © 1991-2023 Unicode, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +data files and any associated documentation (the "Data Files") or software and +any associated documentation (the "Software") to deal in the Data Files or +Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of the Data Files +or Software, and to permit persons to whom the Data Files or Software are +furnished to do so, provided that either (a) this copyright and permission +notice appear with all copies of the Data Files or Software, or (b) this +copyright and permission notice appear in associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD +PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN +THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING +OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR +SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not be +used in advertising or otherwise to promote the sale, use or other dealings in +these Data Files or Software without prior written authorization of the +copyright holder. diff --git a/src/commands/build.rs b/src/commands/build.rs index a4d89d2..3660102 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -27,6 +27,10 @@ struct VexBuildOptions { } pub fn build(mode: BuildMode, args: &[String]) { + if matches!(args, [help] if help == "-h" || help == "--help") { + println!("{}", build_usage(mode)); + return; + } if let Err(err) = run_build(mode, args) { eprintln!("error: {err}"); std::process::exit(1); @@ -35,8 +39,8 @@ pub fn build(mode: BuildMode, args: &[String]) { fn run_build(mode: BuildMode, args: &[String]) -> Result<(), String> { let started = Instant::now(); - let manifest = Manifest::load()?; let options = parse_vex_build_options(mode, args)?; + let manifest = Manifest::load()?; let default_input = resolve_default_input(&manifest, mode)?; let mut global_args = Vec::new(); @@ -147,7 +151,7 @@ fn parse_vex_build_options(mode: BuildMode, args: &[String]) -> Result= args.len() { return Err("missing value for `--target`".to_string()); } - options.target = Some(args[i].clone()); + set_target(&mut options, &args[i])?; } "--release" => options.release = true, "--dry-run" => options.dry_run = true, @@ -155,7 +159,7 @@ fn parse_vex_build_options(mode: BuildMode, args: &[String]) -> Result options.offline = true, "-h" | "--help" => return Err(build_usage(mode).to_string()), _ if token.starts_with("--target=") => { - options.target = Some(token.trim_start_matches("--target=").to_string()); + set_target(&mut options, token.trim_start_matches("--target="))?; } _ if token.starts_with('-') => { return Err(format!( @@ -175,6 +179,17 @@ fn parse_vex_build_options(mode: BuildMode, args: &[String]) -> Result Result<(), String> { + if target.is_empty() { + return Err("`--target` cannot be empty".to_string()); + } + if options.target.is_some() { + return Err("`--target` may only be specified once".to_string()); + } + options.target = Some(target.to_string()); + Ok(()) +} + fn build_usage(mode: BuildMode) -> &'static str { match mode { BuildMode::Build => { @@ -304,4 +319,16 @@ mod tests { .expect_err("Vex run is manifest-based"); assert!(err.contains("vex.ws"), "{err}"); } + + #[test] + fn rejects_empty_and_duplicate_targets() { + for arguments in [ + strings(&["--target="]), + strings(&["--target", ""]), + strings(&["--target", "host", "--target=other"]), + ] { + parse_vex_build_options(BuildMode::Build, &arguments) + .expect_err("invalid target options must be rejected"); + } + } } diff --git a/src/commands/deps.rs b/src/commands/deps.rs index a87e268..e250b28 100644 --- a/src/commands/deps.rs +++ b/src/commands/deps.rs @@ -13,6 +13,17 @@ struct DependencyOptions { } pub fn fetch(update: bool, args: &[String]) { + if matches!(args, [help] if help == "-h" || help == "--help") { + println!( + "usage: vex {}", + if update { + "update [...]" + } else { + "fetch [--locked] [--offline]" + } + ); + return; + } if let Err(err) = run_fetch(update, args) { eprintln!("error: {err}"); std::process::exit(1); diff --git a/src/commands/info.rs b/src/commands/info.rs index a469f80..4c01041 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -1,51 +1,63 @@ use crate::manifest::{DependencySource, Manifest}; -pub fn info() { - match Manifest::load() { - Ok(manifest) => { - println!("Vex project info"); - println!("name: {}", manifest.name); - println!("version: {}", manifest.version); - println!("type: {}", if manifest.lib { "library" } else { "binary" }); - println!("manifest: {}", manifest.source_path.to_string_lossy()); - if let Some(description) = manifest.description.as_ref() { - println!("description: {description}"); - } - if let Some(author) = manifest.author.as_ref() { - println!("author: {author}"); - } - if let Some(license) = manifest.license.as_ref() { - println!("license: {license}"); - } - println!("dependencies: {}", manifest.dependencies.len()); +pub fn info(args: &[String]) { + if matches!(args, [help] if help == "-h" || help == "--help") { + println!("usage: vex info"); + return; + } + if let Some(argument) = args.first() { + eprintln!("error: unexpected argument `{argument}`\nusage: vex info"); + std::process::exit(2); + } + if let Err(err) = run_info() { + eprintln!("error: {err}"); + std::process::exit(1); + } +} + +fn run_info() -> Result<(), String> { + let manifest = Manifest::load()?; + println!("Vex project info"); + println!("name: {}", manifest.name); + println!("version: {}", manifest.version); + println!("type: {}", if manifest.lib { "library" } else { "binary" }); + println!("manifest: {}", manifest.source_path.to_string_lossy()); + if let Some(description) = manifest.description.as_ref() { + println!("description: {description}"); + } + if let Some(author) = manifest.author.as_ref() { + println!("author: {author}"); + } + if let Some(license) = manifest.license.as_ref() { + println!("license: {license}"); + } + println!("dependencies: {}", manifest.dependencies.len()); - for dep in manifest.dependencies { - match dep.source { - DependencySource::Path { path } => match dep.version { - Some(version) => println!(" {} {} path {}", dep.name, version, path), - None => println!(" {} path {}", dep.name, path), - }, - DependencySource::Git { - url, - branch, - tag, - rev, - } => { - let reference = branch - .map(|value| format!(" branch {value}")) - .or_else(|| tag.map(|value| format!(" tag {value}"))) - .or_else(|| rev.map(|value| format!(" rev {value}"))) - .unwrap_or_default(); - match dep.version { - Some(version) => { - println!(" {} {} git {}{}", dep.name, version, url, reference) - } - None => println!(" {} git {}{}", dep.name, url, reference), - } + for dep in manifest.dependencies { + match dep.source { + DependencySource::Path { path } => match dep.version { + Some(version) => println!(" {} {} path {}", dep.name, version, path), + None => println!(" {} path {}", dep.name, path), + }, + DependencySource::Git { + url, + branch, + tag, + rev, + } => { + let reference = branch + .map(|value| format!(" branch {value}")) + .or_else(|| tag.map(|value| format!(" tag {value}"))) + .or_else(|| rev.map(|value| format!(" rev {value}"))) + .unwrap_or_default(); + match dep.version { + Some(version) => { + println!(" {} {} git {}{}", dep.name, version, url, reference) } + None => println!(" {} git {}{}", dep.name, url, reference), } } } - Err(err) => eprintln!("error: {err}"), } + Ok(()) } diff --git a/src/commands/init.rs b/src/commands/init.rs index 41cab3b..6ca609b 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -5,13 +5,38 @@ use std::path::Path; use crate::lockfile::{write_lockfile, Lockfile}; use crate::manifest::{render_new_manifest, Manifest, MANIFEST_FILE}; -pub fn init(is_lib: bool) { +pub fn init(args: &[String]) { + let is_lib = match parse_options(args) { + Ok(Some(is_lib)) => is_lib, + Ok(None) => { + println!("usage: vex init [--lib]"); + return; + } + Err(err) => { + eprintln!("error: {err}"); + eprintln!("usage: vex init [--lib]"); + std::process::exit(2); + } + }; if let Err(err) = run_init(is_lib) { eprintln!("error: {err}"); std::process::exit(1); } } +fn parse_options(args: &[String]) -> Result, String> { + let mut is_lib = false; + for argument in args { + match argument.as_str() { + "--lib" if !is_lib => is_lib = true, + "--lib" => return Err("`--lib` may only be specified once".to_string()), + "-h" | "--help" if args.len() == 1 => return Ok(None), + unknown => return Err(format!("unknown Vex option `{unknown}`")), + } + } + Ok(Some(is_lib)) +} + fn run_init(is_lib: bool) -> Result<(), String> { let manifest_path = Path::new(MANIFEST_FILE); let src_dir = Path::new("src"); @@ -68,3 +93,20 @@ fn get_username() -> Option { env::var("USER").ok() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn init_options_do_not_treat_help_or_unknown_flags_as_creation() { + assert_eq!(parse_options(&strings(&["--help"])), Ok(None)); + assert!(parse_options(&strings(&["--unknown"])).is_err()); + assert!(parse_options(&strings(&["--lib", "--lib"])).is_err()); + assert_eq!(parse_options(&strings(&["--lib"])), Ok(Some(true))); + } +} diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 3e33bc7..23f9ede 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,10 +1,14 @@ use std::env; -use std::fs; +use std::fs::{self, File, OpenOptions}; +use std::io; use std::path::PathBuf; use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; const UNIX_INSTALLER_URL: &str = "https://wave-lang.dev/install.sh"; const WINDOWS_INSTALLER_URL: &str = "https://wave-lang.dev/install.ps1"; +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); pub fn install_wavec(version: Option<&str>) { if let Err(err) = run_install_wavec(version) { @@ -23,22 +27,32 @@ fn run_install_wavec(version: Option<&str>) -> Result<(), String> { println!("installing wavec {display_version}"); - let cleanup_path; - let mut child = if cfg!(windows) { - cleanup_path = Some(download_windows_installer()?); - spawn_windows_installer(cleanup_path.as_ref().unwrap(), &installer_args)? + let installer = if cfg!(windows) { + download_windows_installer()? } else { - cleanup_path = Some(download_unix_installer()?); - spawn_unix_installer(cleanup_path.as_ref().unwrap(), &installer_args)? + download_unix_installer()? + }; + let child = if cfg!(windows) { + spawn_windows_installer(&installer, &installer_args) + } else { + spawn_unix_installer(&installer, &installer_args) + }; + let mut child = match child { + Ok(child) => child, + Err(err) => { + let _ = fs::remove_file(&installer); + return Err(err); + } }; - let status = child - .wait() - .map_err(|e| format!("failed to wait for wavec installer: {e}"))?; - - if let Some(path) = cleanup_path { - let _ = fs::remove_file(path); - } + let status = child.wait(); + fs::remove_file(&installer).map_err(|e| { + format!( + "failed to remove temporary wavec installer `{}`: {e}", + installer.display() + ) + })?; + let status = status.map_err(|e| format!("failed to wait for wavec installer: {e}"))?; if status.success() { println!("wavec installed successfully"); @@ -56,15 +70,22 @@ fn installer_args(version: Option<&str>) -> Vec { } fn download_unix_installer() -> Result { - let script = env::temp_dir().join(format!("wave-install-{}.sh", std::process::id())); + let (script, output) = create_installer_file("sh")?; let status = Command::new("curl") - .args(["-fsSL", UNIX_INSTALLER_URL, "-o"]) - .arg(&script) + .args(["-fsSL", UNIX_INSTALLER_URL]) .stdin(Stdio::null()) - .status() - .map_err(|e| format!("failed to start curl for wavec installer: {e}"))?; + .stdout(Stdio::from(output)) + .status(); + let status = match status { + Ok(status) => status, + Err(error) => { + let _ = fs::remove_file(&script); + return Err(format!("failed to start curl for wavec installer: {error}")); + } + }; if !status.success() { + let _ = fs::remove_file(&script); return Err(format!("failed to download wavec installer: {status}")); } @@ -81,7 +102,8 @@ fn spawn_unix_installer(script: &PathBuf, args: &[String]) -> Result Result { - let script = env::temp_dir().join(format!("wave-install-{}.ps1", std::process::id())); + let (script, output) = create_installer_file("ps1")?; + drop(output); let shell = windows_shell(); let status = Command::new(shell) .args([ @@ -94,16 +116,50 @@ fn download_windows_installer() -> Result { ]) .arg(&script) .stdin(Stdio::null()) - .status() - .map_err(|e| format!("failed to start PowerShell for wavec installer download: {e}"))?; + .status(); + let status = match status { + Ok(status) => status, + Err(error) => { + let _ = fs::remove_file(&script); + return Err(format!( + "failed to start PowerShell for wavec installer download: {error}" + )); + } + }; if !status.success() { + let _ = fs::remove_file(&script); return Err(format!("failed to download wavec installer: {status}")); } Ok(script) } +fn create_installer_file(extension: &str) -> Result<(PathBuf, File), String> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for _ in 0..100 { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = env::temp_dir().join(format!( + "vex-wave-install-{}-{timestamp}-{id}.{extension}", + std::process::id() + )); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => return Ok((path, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "failed to create temporary wavec installer `{}`: {error}", + path.display() + )) + } + } + } + Err("failed to allocate a unique temporary path for the wavec installer".to_string()) +} + fn spawn_windows_installer( script: &PathBuf, args: &[String], @@ -124,3 +180,30 @@ fn windows_shell() -> &'static str { "pwsh" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installer_arguments_preserve_an_explicit_version() { + assert_eq!(installer_args(None), ["latest"]); + assert_eq!( + installer_args(Some("0.2.0-pre-beta")), + ["--version", "0.2.0-pre-beta"] + ); + } + + #[test] + fn installer_temporary_files_are_unique_and_exclusive() { + let (first_path, first_file) = + create_installer_file("test").expect("first temporary installer must be created"); + let (second_path, second_file) = + create_installer_file("test").expect("second temporary installer must be created"); + assert_ne!(first_path, second_path); + drop(first_file); + drop(second_file); + fs::remove_file(first_path).expect("first temporary installer must be removed"); + fs::remove_file(second_path).expect("second temporary installer must be removed"); + } +} diff --git a/src/lockfile.rs b/src/lockfile.rs index 838f9b3..26190e9 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -140,14 +140,18 @@ fn parse_package(value: &WsonValue) -> Result { requested: required_string(object, "path")?, resolved: PathBuf::from(required_string(object, "resolved")?), }, - "git" => LockedSource::Git { - url: required_string(object, "git")?, - branch: optional_string(object, "branch")?, - tag: optional_string(object, "tag")?, - rev: optional_string(object, "rev")?, - commit: required_string(object, "commit")?, - resolved: PathBuf::from(required_string(object, "resolved")?), - }, + "git" => { + let commit = required_string(object, "commit")?; + validate_commit(&commit, &name)?; + LockedSource::Git { + url: required_string(object, "git")?, + branch: optional_string(object, "branch")?, + tag: optional_string(object, "tag")?, + rev: optional_string(object, "rev")?, + commit, + resolved: PathBuf::from(required_string(object, "resolved")?), + } + } other => { return Err(format!( "unknown lockfile source `{other}` for package `{name}`" @@ -163,6 +167,15 @@ fn parse_package(value: &WsonValue) -> Result { }) } +fn validate_commit(commit: &str, package: &str) -> Result<(), String> { + if matches!(commit.len(), 40 | 64) && commit.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Ok(()); + } + Err(format!( + "lockfile package `{package}` has invalid Git commit `{commit}`; expected a full hexadecimal object ID" + )) +} + fn required_string(object: &WsonMap, key: &str) -> Result { match object.get(key) { Some(WsonValue::String(value)) => Ok(restore_url(value)), @@ -287,7 +300,7 @@ mod tests { branch: Some("main".to_string()), tag: None, rev: None, - commit: "0123456789abcdef".to_string(), + commit: "0123456789abcdef0123456789abcdef01234567".to_string(), resolved: PathBuf::from(".vex/deps/math"), }, dependencies: vec!["core".to_string()], @@ -305,4 +318,24 @@ mod tests { .expect("legacy lockfile should trigger regeneration"); assert!(parsed.packages.is_empty()); } + + #[test] + fn rejects_non_object_id_git_commits() { + let err = parse_lockfile( + r#"{ + version = 2, + package = [{ + name = "bad", + version = "1.0.0", + source = "git", + git = "https://example.com/bad.git", + commit = "--help", + resolved = ".vex/deps/bad", + dependencies = [] + }] + }"#, + ) + .expect_err("Git commits must be full object IDs"); + assert!(err.contains("invalid Git commit"), "{err}"); + } } diff --git a/src/main.rs b/src/main.rs index a2b4997..d5a76bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,19 +25,20 @@ fn main() { } match args[0].as_str() { - "init" => { - let is_lib = args[1..].iter().any(|arg| arg == "--lib"); - init(is_lib); - } + "init" => init(&args[1..]), "build" => build(BuildMode::Build, &args[1..]), "run" => run(&args[1..]), "check" => check(&args[1..]), "fetch" => fetch(false, &args[1..]), "update" => fetch(true, &args[1..]), - "info" => info(), + "info" => info(&args[1..]), "setup" => setup(&args[1..]), - "--version" | "-V" | "version" => version_vex(), - "--help" | "-h" | "help" => print_help(), + "--version" | "-V" | "version" if args.len() == 1 => version_vex(), + "--help" | "-h" | "help" if args.len() == 1 => print_help(), + "--version" | "-V" | "version" | "--help" | "-h" | "help" => { + eprintln!("error: unexpected argument `{}`", args[1]); + std::process::exit(2); + } unknown => { eprintln!("error: unknown command `{unknown}`"); print_help(); @@ -47,6 +48,12 @@ fn main() { } fn setup(args: &[String]) { + if matches!(args, [help] if help == "-h" || help == "--help") + || matches!(args, [wavec, help] if wavec == "wavec" && (help == "-h" || help == "--help")) + { + println!("usage: vex setup wavec [--version ]"); + return; + } if args.first().map(String::as_str) != Some("wavec") { eprintln!("error: usage: vex setup wavec [--version ]"); std::process::exit(2); @@ -58,10 +65,18 @@ fn setup(args: &[String]) { while i < args.len() { match args[i].as_str() { "--version" => { + if version.is_some() { + eprintln!("error: `--version` may only be specified once"); + std::process::exit(2); + } if i + 1 >= args.len() { eprintln!("error: missing value for --version"); std::process::exit(2); } + if args[i + 1].is_empty() || args[i + 1].starts_with('-') { + eprintln!("error: `--version` must be a version value, not an option"); + std::process::exit(2); + } version = Some(args[i + 1].as_str()); i += 2; } diff --git a/src/resolver.rs b/src/resolver.rs index 59cefc1..04b10e0 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, ExitStatus}; use crate::lockfile::{ @@ -99,9 +99,11 @@ pub fn resolve(manifest: &Manifest, options: ResolveOptions) -> Result { let resolved = resolve_path(path, parent_manifest); let source = LockedSource::Path { requested: path.clone(), - resolved: resolved.clone(), + resolved: relative_to_root(&resolved, &self.root), }; (resolved, source) } @@ -453,7 +455,12 @@ impl Resolver<'_> { "refs/remotes/origin/HEAD^{commit}".to_string() }; let commit = git_stdout( - git_command_in(destination).args(["rev-parse", "--verify", &reference]), + git_command_in(destination).args([ + "rev-parse", + "--verify", + "--end-of-options", + &reference, + ]), "resolve Git dependency reference", )?; checkout_commit(destination, &commit)?; @@ -481,12 +488,45 @@ fn resolve_path(path: &str, manifest_path: &Path) -> PathBuf { } fn relative_to_root(path: &Path, root: &Path) -> PathBuf { - path.strip_prefix(root) - .map(Path::to_path_buf) - .unwrap_or_else(|_| path.to_path_buf()) + if let Ok(relative) = path.strip_prefix(root) { + return relative.to_path_buf(); + } + + let path_components = path.components().collect::>(); + let root_components = root.components().collect::>(); + let common = path_components + .iter() + .zip(&root_components) + .take_while(|(path_component, root_component)| path_component == root_component) + .count(); + + let same_root = common > 0 + && !matches!( + (path_components.get(common), root_components.get(common)), + (Some(Component::Prefix(_)), _) | (_, Some(Component::Prefix(_))) + ); + if !same_root { + return path.to_path_buf(); + } + + let mut relative = PathBuf::new(); + for component in &root_components[common..] { + if matches!(component, Component::Normal(_)) { + relative.push(".."); + } + } + for component in &path_components[common..] { + relative.push(component.as_os_str()); + } + if relative.as_os_str().is_empty() { + PathBuf::from(".") + } else { + relative + } } fn ensure_repository(destination: &Path, url: &str, name: &str) -> Result<(), String> { + validate_managed_checkout_path(destination)?; if destination.exists() { if !destination.join(".git").is_dir() { return Err(format!( @@ -507,7 +547,7 @@ fn ensure_repository(destination: &Path, url: &str, name: &str) -> Result<(), St let destination = git_cli_path(destination); run_git( Command::new("git") - .args(["clone", url]) + .args(["-c", "protocol.ext.allow=never", "clone", "--", url]) .arg(destination.as_ref()), "clone Git dependency", ) @@ -519,6 +559,7 @@ fn require_local_repository( name: &str, commit: &str, ) -> Result<(), String> { + validate_managed_checkout_path(destination)?; if !destination.join(".git").is_dir() { return Err(format!( "locked dependency `{name}` is not available locally in offline mode\n\nCaused by:\n checkout `{}` is missing\n\nhelp: run `vex fetch` while online", @@ -565,6 +606,7 @@ fn git_has_commit(destination: &Path, commit: &str) -> Result { } fn require_checkout_at(destination: &Path, url: &str, commit: &str) -> Result<(), String> { + validate_managed_checkout_path(destination)?; if !destination.join(".git").is_dir() { return Err(format!( "locked Git dependency is not available at `{}`\nhelp: run `vex fetch`", @@ -613,10 +655,37 @@ fn checkout_commit(destination: &Path, commit: &str) -> Result<(), String> { fn git_command_in(destination: &Path) -> Command { let mut command = Command::new("git"); - command.arg("-C").arg(git_cli_path(destination).as_ref()); + command + .args(["-c", "protocol.ext.allow=never", "-C"]) + .arg(git_cli_path(destination).as_ref()); command } +fn validate_managed_root(root: &Path, dep_root: &Path) -> Result<(), String> { + reject_symbolic_link(&root.join(".vex"), "managed Vex directory")?; + reject_symbolic_link(dep_root, "managed dependency directory") +} + +fn validate_managed_checkout_path(destination: &Path) -> Result<(), String> { + reject_symbolic_link(destination, "managed dependency checkout")?; + reject_symbolic_link( + &destination.join(".git"), + "managed dependency Git directory", + ) +} + +fn reject_symbolic_link(path: &Path, label: &str) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(format!( + "{label} `{}` must not be a symbolic link\nhelp: replace the link with a real directory before running Vex", + path.display() + )), + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to inspect `{}`: {error}", path.display())), + } +} + fn git_cli_path(path: &Path) -> Cow<'_, Path> { #[cfg(windows)] { @@ -715,6 +784,45 @@ mod tests { assert_eq!(first, second); } + #[test] + fn paths_outside_the_project_are_locked_relative_to_the_project() { + let root = Path::new("/workspace/app"); + assert_eq!( + relative_to_root(Path::new("/workspace/dep"), root), + PathBuf::from("../dep") + ); + assert_eq!( + relative_to_root(Path::new("/workspace/app/.vex/deps/remote"), root), + PathBuf::from(".vex/deps/remote") + ); + } + + #[cfg(unix)] + #[test] + fn managed_dependency_links_are_rejected() { + use std::os::unix::fs::symlink; + use std::time::{SystemTime, UNIX_EPOCH}; + + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("test clock must be after the Unix epoch") + .as_nanos(); + let root = + std::env::temp_dir().join(format!("vex-managed-link-test-{}-{id}", std::process::id())); + let outside = root.with_extension("outside"); + fs::create_dir_all(&root).expect("test project must be created"); + fs::create_dir_all(&outside).expect("outside directory must be created"); + symlink(&outside, root.join(".vex")).expect("test link must be created"); + + let error = validate_managed_root(&root, &root.join(".vex/deps")) + .expect_err("managed root symlinks must be rejected"); + assert!(error.contains("must not be a symbolic link"), "{error}"); + + fs::remove_file(root.join(".vex")).expect("test link must be removed"); + fs::remove_dir_all(root).expect("test project must be removed"); + fs::remove_dir_all(outside).expect("outside directory must be removed"); + } + #[cfg(windows)] #[test] fn git_cli_path_removes_verbatim_disk_prefix() { diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs new file mode 100644 index 0000000..6aef863 --- /dev/null +++ b/tests/cli_contract.rs @@ -0,0 +1,93 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("vex-cli-contract-{}-{id}", std::process::id())); + fs::create_dir_all(&path).expect("test directory must be created"); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn help_is_read_only_and_succeeds_without_a_manifest() { + let fixture = TestDir::new(); + for arguments in [ + &["init", "--help"][..], + &["build", "--help"], + &["run", "--help"], + &["check", "--help"], + &["fetch", "--help"], + &["update", "--help"], + &["info", "--help"], + &["setup", "--help"], + &["setup", "wavec", "--help"], + ] { + let output = vex(&fixture.0, arguments); + assert_success(&output, &format!("vex {}", arguments.join(" "))); + assert!( + String::from_utf8_lossy(&output.stdout).contains("usage:"), + "help output did not contain usage: {}", + String::from_utf8_lossy(&output.stdout) + ); + } + assert!(!fixture.0.join("vex.ws").exists()); + assert!(!fixture.0.join("src").exists()); +} + +#[test] +fn invalid_init_and_info_fail_without_mutating_the_directory() { + let fixture = TestDir::new(); + let invalid_init = vex(&fixture.0, &["init", "--unknown"]); + assert_eq!(invalid_init.status.code(), Some(2)); + assert!(!fixture.0.join("vex.ws").exists()); + assert!(!fixture.0.join("src").exists()); + + let missing_info = vex(&fixture.0, &["info"]); + assert_eq!(missing_info.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&missing_info.stderr).contains("could not find `vex.ws`")); + + let invalid_info = vex(&fixture.0, &["info", "extra"]); + assert_eq!(invalid_info.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid_info.stderr).contains("unexpected argument")); + + let invalid_setup = vex(&fixture.0, &["setup", "wavec", "--version", "--unknown"]); + assert_eq!(invalid_setup.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid_setup.stderr).contains("version value")); +} + +fn vex(path: &PathBuf, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_vex")) + .args(args) + .current_dir(path) + .output() + .expect("vex command must start") +} + +fn assert_success(output: &Output, action: &str) { + assert!( + output.status.success(), + "{action} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/path_lock_portability.rs b/tests/path_lock_portability.rs new file mode 100644 index 0000000..fba1fb2 --- /dev/null +++ b/tests/path_lock_portability.rs @@ -0,0 +1,92 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "vex-path-lock-portability-{}-{id}", + std::process::id() + )); + fs::create_dir_all(&path).expect("test directory must be created"); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn path_lock_survives_moving_the_same_relative_package_tree() { + let fixture = TestDir::new(); + let first = fixture.0.join("first"); + let second = fixture.0.join("second"); + create_tree(&first); + + let initial = vex(&first.join("app"), &["fetch"]); + assert_success(&initial, "initial path dependency fetch"); + let lock = fs::read_to_string(first.join("app/vex.lock")).expect("lockfile must exist"); + let first_dependency = first + .join("dep") + .canonicalize() + .expect("dependency path must canonicalize"); + assert!( + !lock.contains(&first_dependency.to_string_lossy().to_string()), + "lockfile leaked an absolute path:\n{lock}" + ); + + create_tree(&second); + fs::write(second.join("app/vex.lock"), &lock).expect("lockfile must be copied"); + let moved = vex(&second.join("app"), &["fetch", "--locked", "--offline"]); + assert_success(&moved, "locked offline fetch after moving package tree"); + assert_eq!( + fs::read_to_string(second.join("app/vex.lock")).expect("moved lockfile must exist"), + lock + ); +} + +fn create_tree(root: &Path) { + fs::create_dir_all(root.join("app")).expect("app directory must be created"); + fs::create_dir_all(root.join("dep")).expect("dependency directory must be created"); + fs::write( + root.join("dep/vex.ws"), + "{\n name = \"dep\",\n version = 0.1.0,\n lib = true,\n dependencies = []\n}\n", + ) + .expect("dependency manifest must be written"); + fs::write( + root.join("app/vex.ws"), + "{\n name = \"app\",\n version = 0.1.0,\n dependencies = [{ name = \"dep\", path = \"../dep\" }]\n}\n", + ) + .expect("app manifest must be written"); +} + +fn vex(path: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_vex")) + .args(args) + .current_dir(path) + .output() + .expect("vex command must start") +} + +fn assert_success(output: &Output, action: &str) { + assert!( + output.status.success(), + "{action} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/wavec_contract.rs b/tests/wavec_contract.rs new file mode 100644 index 0000000..05dd01a --- /dev/null +++ b/tests/wavec_contract.rs @@ -0,0 +1,174 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// SPDX-License-Identifier: MPL-2.0 + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new() -> Self { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = env::temp_dir().join(format!("vex-wavec-contract-{}-{id}", std::process::id())); + fs::create_dir_all(&path).expect("test directory must be created"); + Self(path) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn vex_uses_path_and_override_wavec_and_rejects_unknown_schema() { + let fixture = TestDir::new(); + let project = fixture.0.join("project"); + fs::create_dir_all(project.join("src")).expect("project source directory must be created"); + fs::write( + project.join("vex.ws"), + "{ name = \"app\", version = 0.1.0, dependencies = [] }\n", + ) + .expect("manifest must be written"); + fs::write(project.join("vex.lock"), "{ version = 2, package = [] }\n") + .expect("lockfile must be written"); + fs::write( + project.join("src/main.wave"), + "fun main() { println(\"Hello World\"); }\n", + ) + .expect("source must be written"); + + let fake = compile_fake_wavec(&fixture.0); + let override_log = fixture.0.join("override.log"); + let override_run = Command::new(env!("CARGO_BIN_EXE_vex")) + .args(["run", "--locked", "--offline"]) + .current_dir(&project) + .env("VEX_WAVEC", &fake) + .env("FAKE_WAVEC_LOG", &override_log) + .output() + .expect("Vex with VEX_WAVEC must start"); + assert_success(&override_run, "VEX_WAVEC override run"); + assert!(String::from_utf8_lossy(&override_run.stdout).contains("FAKE_WAVEC_EXECUTED")); + assert_contract_invocations(&override_log); + + let bin_dir = fixture.0.join("bin"); + fs::create_dir_all(&bin_dir).expect("fake PATH directory must be created"); + let path_wavec = bin_dir.join(if cfg!(windows) { "wavec.exe" } else { "wavec" }); + fs::copy(&fake, &path_wavec).expect("fake PATH wavec must be copied"); + let path_log = fixture.0.join("path.log"); + let path = env::join_paths( + std::iter::once(bin_dir).chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), + ) + .expect("PATH must be assembled"); + let path_build = Command::new(env!("CARGO_BIN_EXE_vex")) + .arg("build") + .current_dir(&project) + .env_remove("VEX_WAVEC") + .env("PATH", path) + .env("FAKE_WAVEC_LOG", &path_log) + .output() + .expect("Vex with PATH wavec must start"); + assert_success(&path_build, "PATH wavec build"); + assert_contract_invocations(&path_log); + + let schema_log = fixture.0.join("schema.log"); + let incompatible = Command::new(env!("CARGO_BIN_EXE_vex")) + .arg("build") + .current_dir(&project) + .env("VEX_WAVEC", &fake) + .env("FAKE_WAVEC_LOG", &schema_log) + .env("FAKE_SCHEMA", "2") + .output() + .expect("Vex incompatible-schema check must start"); + assert!(!incompatible.status.success()); + let stderr = String::from_utf8_lossy(&incompatible.stderr); + assert!( + stderr.contains("schema_version `2`; expected `1`"), + "{stderr}" + ); + assert!(stderr.contains("VEX_WAVEC=/path/to/wavec"), "{stderr}"); + assert_eq!( + fs::read_to_string(schema_log) + .expect("schema log must exist") + .lines() + .count(), + 1, + "Vex must not execute a real build after incompatible dry-run output" + ); +} + +fn compile_fake_wavec(root: &Path) -> PathBuf { + let source = root.join("fake_wavec.rs"); + fs::write( + &source, + r#" +use std::env; +use std::fs::OpenOptions; +use std::io::Write; + +fn main() { + let args = env::args().skip(1).collect::>(); + if let Ok(path) = env::var("FAKE_WAVEC_LOG") { + let mut log = OpenOptions::new().create(true).append(true).open(path).unwrap(); + writeln!(log, "{}", args.join(" ")).unwrap(); + } + if args.iter().any(|arg| arg == "--dry-run") { + let schema = env::var("FAKE_SCHEMA").unwrap_or_else(|_| "1".to_string()); + println!( + "{{\"schema_version\":{schema},\"mode\":\"build\",\"target\":\"test-target\",\"emit\":\"bin\",\"emit_kinds\":[],\"control_mode\":null,\"forced_input_type\":null,\"inputs\":[],\"emit_jobs\":[],\"compile\":[],\"link\":null,\"execute\":null}}" + ); + } else { + println!("FAKE_WAVEC_EXECUTED"); + } +} +"#, + ) + .expect("fake wavec source must be written"); + let binary = root.join(if cfg!(windows) { + "fake-wavec.exe" + } else { + "fake-wavec" + }); + let compile = Command::new("rustc") + .args(["--edition=2021"]) + .arg(&source) + .arg("-o") + .arg(&binary) + .output() + .expect("rustc for fake wavec must start"); + assert_success(&compile, "compile fake wavec"); + binary +} + +fn assert_contract_invocations(log: &Path) { + let lines = fs::read_to_string(log).expect("fake wavec log must exist"); + let lines = lines.lines().collect::>(); + assert_eq!( + lines.len(), + 2, + "expected dry-run and real invocation: {lines:?}" + ); + assert!(lines[0].contains("build"), "{lines:?}"); + assert!(lines[0].contains("--dry-run"), "{lines:?}"); + assert!(lines[0].contains("--error-format=json"), "{lines:?}"); + assert!(lines[1].contains("build"), "{lines:?}"); + assert!(!lines[1].contains("--dry-run"), "{lines:?}"); + assert!(!lines[1].contains("--error-format=json"), "{lines:?}"); +} + +fn assert_success(output: &Output, action: &str) { + assert!( + output.status.success(), + "{action} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tests/xpy/test_release_tool.py b/tests/xpy/test_release_tool.py index 8237a4f..2432ea1 100644 --- a/tests/xpy/test_release_tool.py +++ b/tests/xpy/test_release_tool.py @@ -45,6 +45,36 @@ def test_load_version_accepts_full_semver(self) -> None: ) self.assertEqual(release_tool.load_version(manifest), "1.2.3-rc.1+build.7") + def test_third_party_license_inventory_covers_locked_registry_packages(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + notice = Path(temporary) / "THIRD_PARTY_LICENSES.md" + lockfile = Path(temporary) / "Cargo.lock" + lockfile.write_text( + """version = 4 + +[[package]] +name = "example" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "local" +version = "0.0.1" +""", + encoding="utf-8", + ) + notice.write_text( + "https://crates.io/crates/example/1.2.3\n", encoding="utf-8" + ) + release_tool.validate_third_party_licenses(notice, lockfile) + + notice.write_text("missing\n", encoding="utf-8") + with self.assertRaisesRegex( + release_tool.ReleaseError, + "missing from THIRD_PARTY_LICENSES.md: example 1.2.3", + ): + release_tool.validate_third_party_licenses(notice, lockfile) + def test_select_targets_deduplicates_without_reordering(self) -> None: selected = release_tool.select_targets( [ @@ -228,6 +258,7 @@ def test_release_workflow_covers_every_supported_target(self) -> None: self.assertIn("python x.py checksum", workflow) self.assertIn("uses: actions/attest@v4", workflow) self.assertIn("--draft", workflow) + self.assertIn("--notes-file RELEASE_NOTES.md", workflow) @staticmethod def make_package_inputs(root: Path, target: object, binary: bytes) -> None: diff --git a/x.py b/x.py index 7aa4912..38681d1 100755 --- a/x.py +++ b/x.py @@ -34,7 +34,14 @@ TARGET_DIR = ROOT / "target" DIST_DIR = ROOT / "dist" BINARY_NAME = "vex" -PACKAGE_DOCUMENTS = ("README.md", "LICENSE", "NOTICE", "COPYRIGHT") +PACKAGE_DOCUMENTS = ( + "README.md", + "CHANGELOG.md", + "LICENSE", + "NOTICE", + "COPYRIGHT", + "THIRD_PARTY_LICENSES.md", +) CHECKSUM_FILE = "SHA256SUMS" MINIMUM_ZIP_EPOCH = 315532800 # 1980-01-01T00:00:00Z MAXIMUM_ZIP_EPOCH = 4354819198 # 2107-12-31T23:59:58Z @@ -514,12 +521,46 @@ def verify_release_source(version: str) -> None: require_release_tag(version) +def validate_third_party_licenses( + notice_path: Path = ROOT / "THIRD_PARTY_LICENSES.md", + lockfile_path: Path = ROOT / "Cargo.lock", +) -> None: + if tomllib is None: + raise ReleaseError("Python 3.11 or newer is required to read Cargo.lock") + try: + with lockfile_path.open("rb") as lockfile: + metadata = tomllib.load(lockfile) + notice = notice_path.read_text(encoding="utf-8") + except (OSError, tomllib.TOMLDecodeError) as error: + raise ReleaseError(f"could not validate third-party license inventory: {error}") from error + + packages = sorted( + (package["name"], package["version"]) + for package in metadata.get("package", []) + if str(package.get("source", "")).startswith("registry+") + ) + missing_notices = [ + f"{name} {version}" + for name, version in packages + if f"https://crates.io/crates/{name}/{version}" not in notice + ] + if missing_notices: + raise ReleaseError( + "third-party license inventory is incomplete (missing from " + "THIRD_PARTY_LICENSES.md: " + + ", ".join(missing_notices) + + ")" + ) + status("Audited", f"third-party licenses for {len(packages)} locked packages") + + def run_check_suite() -> None: run_command(["cargo", "fmt", "--check"]) run_python_tests() run_command(["cargo", "test", "--locked"]) run_command(["cargo", "clippy", "--locked", "--all-targets", "--", "-D", "warnings"]) run_command(["cargo", "build", "--locked"]) + validate_third_party_licenses() def command_check(_: argparse.Namespace) -> None: