Repository navigation
Description
Summary
The single-file web build (semaprax build <file> --target web -o <dir> without --export, which calls wasm::build_web) accepts a symlink as its immediate output parent, while native single-file output and the Public Scalar Export publisher reject that parent with SPX-I301. With link -> real, building to link/webout creates real/webout. This is inconsistent parent admission across build routes, so I am filing it as a consistency request.
Environment
- Repository: github.com/wavect/semaprax at commit 2d215d3
- Binary:
target/debug/semaprax, sha25616542ba925d65ead8e1b3c7e070f7e2abac39fd3479dbf78c0786435d287ae72;--versionprintssemaprax 0.9.0 (commit unknown). The checkout was clean at the stated commit when tested. - Host: macOS 27.2 (Darwin 27.2.0), arm64
- Toolchain: rustc 1.98.1 (48a229cea 2026-09-01); cargo 1.98.1 (797e8a9bc 2026-08-05)
Fixture
app.spx (sha256 0ee7c11b9ef381ac9ea706157e8d3fd1cb2ee05640a46fc23742043ed00e2fa1):
module t.web;
@id("t.main")
fn main() -> i64
{
42
}
Directory setup, in the directory that holds app.spx:
$ mkdir real && ln -s real link
The fixture is valid:
$ semaprax fmt --check app.spx; echo rc=$?
rc=0
$ semaprax check app.spx; echo rc=$?
verified app.spx (sha256:e20a4d34fad68e933f4fd0bb535ff505343cbf837a38d87734037a69d15cad66)
rc=0
A copy of examples/calculator.spx at this commit gives the same result: native rc 1 with the same SPX-I301 line, web rc 0 with the same five files in real/webout.
Steps and observed output
The native build refuses the symlinked parent and writes nothing. The SPX-I301 line is on stderr; stdout is empty.
$ semaprax build app.spx -o link/native; echo rc=$?
error[SPX-I301]: single-file native output parent must be a real non-reparse directory
rc=1
$ ls -A real; echo rc=$?
rc=0
The web build accepts the same parent. The line built web package link/webout is on stdout; stderr is empty.
$ semaprax build app.spx --target web -o link/webout; echo rc=$?
built web package link/webout
rc=0
$ ls real/webout; echo rc=$?
app.wasm
index.html
package.json
semaprax.js
semaprax.manifest.json
rc=0
$ readlink link
real
The symlink itself is left unchanged. The same result (native SPX-I301, web rc 0 with five files in real/webout) appears with app.spx and a two-hop chain link2 -> link -> real, and with the calculator copy when the source path, the symlink target and the -o path are all absolute.
A symlink as the final output path, not the parent, is refused on the web route:
$ semaprax build app.spx --target web -o outlink # outlink -> tgt, an existing directory
error[SPX-I307]: cannot create fresh web output outlink: File exists (os error 17)
rc=1
$ semaprax build app.spx --target web -o dangling # dangling -> a missing path
error[SPX-I307]: cannot create fresh web output dangling: File exists (os error 17)
rc=1
Among these tested cases, a symlinked immediate parent is accepted, while an existing symlink at the output leaf is rejected.
Expected behaviour
I expected the web build to reject a symlinked immediate output parent with SPX-I301 before it creates the destination, and to write nothing, as the native single-file build does. This concerns the immediate parent only, not symlinked ancestors higher up or a parent replaced during the build.
docs/WASM-SCALAR-EXPORTS-V1.md:223 says "Publication rejects symlink/reparse parents and children" for the scalar export publisher. docs/WASM-INTERNAL-STRINGS-WEB-V1.md:135 says the internal-strings route's output "real non-reparse parent must already exist". Neither text states this rule for the build_web route. If following the link is intended for this route, a line in the docs saying so would also settle the question.
Where in the code
wasm::build_web (src/wasm.rs:1995) is reached from src/cli_driver/source_execution.rs:100 when no --export is given:
pub fn build_web(program: &Program, output: &Path) -> Result<(), Diagnostic> {src/wasm.rs:2035-2048. The destination is created directly. There is no symlink_metadata or is_plain_directory call between lines 1995 and 2035.
std::fs::create_dir(output).map_err(|error| {
let code = if error.kind() == std::io::ErrorKind::AlreadyExists {
"SPX-I307"
} else {
"SPX-I301"
};
Diagnostic::io(
code,
format!(
"cannot create fresh web output {}: {error}",
output.display()
),
)
})?;The native single-file build checks its parent at src/cli/build.rs:77-87. publish_scalar_package (src/wasm.rs:2978) does the same at src/wasm.rs:2989-3003:
let parent_metadata = fs::symlink_metadata(parent_path).map_err(|error| {
Diagnostic::io(
"SPX-I301",
format!(
"cannot inspect Public Scalar Export output parent {}: {error}",
parent_path.display()
),
)
})?;
if !is_plain_directory(&parent_metadata) {
return Err(Diagnostic::io(
"SPX-I301",
"Public Scalar Export output parent must be a real non-reparse directory",
));
}is_plain_directory is already defined in src/wasm.rs:3097.
Suggested fix
Before create_dir, normalize the immediate parent as publish_scalar_package does, treating a bare output name such as webout as having parent .. Inspect that parent with fs::symlink_metadata and return SPX-I301 unless is_plain_directory holds. Add a regression asserting SPX-I301 and an unchanged empty link target for a symlinked parent, plus a success case for a bare output name under a real parent. Preserve the existing SPX-I307 rejection of an existing output leaf. This check covers the immediate parent only; it does not reject symlinked ancestors or protect against concurrent parent replacement.
Related
- Single-file build -o silently overwrites existing files (including the .spx source) and merges into non-empty directories #37 (closed): also about
build_web, but a different defect (overwriting and merging into an existing destination). Its fix, 85674b2, introduced the currentcreate_dircall but added no parent check. - CLI consistency sweep: exit-code classes, silent check --json, build defaults and target list, patch no-op and read-only writes, wording #40 (closed): item 23 covers
fmtwriting through symlinked sources, a different command.
Not verified
- Windows reparse points were not tested.
- Only the debug binary on macOS was run.
- Project builds were not tested. They use separate project publishers rather than the single-file
wasm::build_webroute (src/cli_driver.rs:717;src/project/mod.rs:978-1028). - Parent replacement during the build was not tested.
Written by an AI agent loop, lightly reviewed by a human. All commands were re-run at the commit above.
Activity