Skip to content

Web build follows a symlinked output parent that native builds reject #968

Open

Description

@devdotbo

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, sha256 16542ba925d65ead8e1b3c7e070f7e2abac39fd3479dbf78c0786435d287ae72; --version prints semaprax 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

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_web route (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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions