Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package dysk for openSUSE:Factory checked in 
at 2026-11-05 18:22:17
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/dysk (Old)
 and      /work/SRC/openSUSE:Factory/.dysk.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "dysk"

Thu Nov  5 18:22:17 2026 rev:19 rq:1395123 version:3.8.2

Changes:
--------
--- /work/SRC/openSUSE:Factory/dysk/dysk.changes        2026-10-20 
12:39:13.616761091 +0200
+++ /work/SRC/openSUSE:Factory/.dysk.new.3759290/dysk.changes   2026-11-05 
18:23:32.415762900 +0100
@@ -1,0 +2,11 @@
+Wed Nov 04 09:56:07 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- Update to version 3.8.2:
+  * explain file locations
+  * update CONTRIBUTING rules
+  * update changelog
+  * Show control characters as \xNN escapes
+  * fix terminal escape injection from mount metadata
+  * on release, upload per target binaries
+
+-------------------------------------------------------------------

Old:
----
  dysk-3.8.1.obscpio

New:
----
  dysk-3.8.2.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ dysk.spec ++++++
--- /var/tmp/diff_new_pack.6dP1Iz/_old  2026-11-05 18:23:34.933867827 +0100
+++ /var/tmp/diff_new_pack.6dP1Iz/_new  2026-11-05 18:23:34.935867910 +0100
@@ -17,7 +17,7 @@
 
 
 Name:           dysk
-Version:        3.8.1
+Version:        3.8.2
 Release:        0
 Summary:        Get information on filesystems, like df but better
 License:        MIT

++++++ _service ++++++
--- /var/tmp/diff_new_pack.6dP1Iz/_old  2026-11-05 18:23:35.063873244 +0100
+++ /var/tmp/diff_new_pack.6dP1Iz/_new  2026-11-05 18:23:35.077873827 +0100
@@ -4,7 +4,7 @@
     <param name="scm">git</param>
     <param name="exclude">.git</param>
     <param name="versionformat">@PARENT_TAG@</param>
-    <param name="revision">refs/tags/v3.8.1</param>
+    <param name="revision">refs/tags/v3.8.2</param>
     <param name="versionrewrite-pattern">v(.*)</param>
     <param name="changesgenerate">enable</param>
   </service>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.6dP1Iz/_old  2026-11-05 18:23:35.167877577 +0100
+++ /var/tmp/diff_new_pack.6dP1Iz/_new  2026-11-05 18:23:35.178878036 +0100
@@ -3,6 +3,6 @@
                 <param name="url">https://github.com/Canop/dysk</param>
               <param 
name="changesrevision">b8c61ea5f565759b12785658a1c6a80f3b13722c</param></service><service
 name="tar_scm">
                 <param name="url">https://github.com/Canop/dysk.git</param>
-              <param 
name="changesrevision">c002b844cfc33d56aaa7a4c46b80b033b07e0dee</param></service></servicedata>
+              <param 
name="changesrevision">990df3edc2a1312d2f943064f605025a45691c55</param></service></servicedata>
 (No newline at EOF)
 

++++++ dysk-3.8.1.obscpio -> dysk-3.8.2.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/CHANGELOG.md new/dysk-3.8.2/CHANGELOG.md
--- old/dysk-3.8.1/CHANGELOG.md 2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/CHANGELOG.md 2026-11-02 17:55:48.000000000 +0100
@@ -1,3 +1,8 @@
+<a name="v3.8.2"></a>
+### v3.8.2 - 2026/11/02
+- GitHub releases come with one archive per target, and checksums - Fix #67
+- sanitize terminal output to prevent escape sequence injection - Fix #125 - 
Thanks @itulo
+
 <a name="v3.8.1"></a>
 ### v3.8.1 - 2026/10/17
 - fix `free_percent` filter, eg `-f 'free_percent>20%'`
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/CONTRIBUTING.md 
new/dysk-3.8.2/CONTRIBUTING.md
--- old/dysk-3.8.1/CONTRIBUTING.md      2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/CONTRIBUTING.md      2026-11-02 17:55:48.000000000 +0100
@@ -1,5 +1,11 @@
+This open-source non commercial project favors human interaction over 
LLM-assisted productivity.
 
-Before you start, unless you're fixing a typo or proposing a trivial change, 
please
+AI agents are not allowed to open PRs on this project.
+
+A PR description MUST be written by an human.
+The text of an issue MUST be written by an human.
+
+Before you start a PR, unless you're fixing a typo or proposing a trivial 
change, please
 
 - discuss the need and technical design first, either in an issue or on 
[miaou](https://miaou.dystroy.org/4847?dysk)
 - keep it simple and focused
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/Cargo.lock new/dysk-3.8.2/Cargo.lock
--- old/dysk-3.8.1/Cargo.lock   2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/Cargo.lock   2026-11-02 17:55:48.000000000 +0100
@@ -337,7 +337,7 @@
 
 [[package]]
 name = "dysk"
-version = "3.8.1"
+version = "3.8.2"
 dependencies = [
  "clap",
  "clap_complete",
@@ -349,7 +349,7 @@
 
 [[package]]
 name = "dysk-cli"
-version = "3.8.1"
+version = "3.8.2"
 dependencies = [
  "bet",
  "clap",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/Cargo.toml new/dysk-3.8.2/Cargo.toml
--- old/dysk-3.8.1/Cargo.toml   2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/Cargo.toml   2026-11-02 17:55:48.000000000 +0100
@@ -1,6 +1,6 @@
 [package]
 name = "dysk"
-version = "3.8.1"
+version = "3.8.2"
 authors = ["dystroy <[email protected]>"]
 edition = "2024"
 keywords = ["linux", "mac", "filesystem", "disk"]
@@ -22,13 +22,13 @@
 ]
 
 [dependencies]
-dysk-cli = { version = "3.8.1", path = "cli" } # beware: version is also in 
build dependencies
+dysk-cli = { version = "3.8.2", path = "cli" } # beware: version is also in 
build dependencies
 
 [build-dependencies]
 clap = { version = "4.6", features = ["derive", "cargo"] }
 clap_complete = "4.6"
 clap_mangen = "0.3"
-dysk-cli = { version = "3.8.1", path = "cli" }
+dysk-cli = { version = "3.8.2", path = "cli" }
 serde = { version = "1.0", features = ["derive"] }
 toml = "1"
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/build-scripts/README.md 
new/dysk-3.8.2/build-scripts/README.md
--- old/dysk-3.8.1/build-scripts/README.md      2026-10-17 18:04:08.000000000 
+0200
+++ new/dysk-3.8.2/build-scripts/README.md      2026-11-02 17:55:48.000000000 
+0100
@@ -31,9 +31,15 @@
 
    Refuses to go on unless HEAD is exactly `origin/main` (not unpushed, not
    behind) and no `v<version>` tag exists on origin at another commit. Then
-   fetches the staged artifacts, checks every target is present, verifies each
-   binary, and produces `dysk_<version>.zip` (also the artifact to attach to 
the
-   GitHub release), with a copy in `releases/`.
+   fetches the staged artifacts (or, without staging, builds locally, which 
only
+   a Mac can do completely), checks every target is present, verifies each
+   binary, and produces `dysk_<version>.zip`, with a copy in `releases/`, and
+   `dist/`, the files of the GitHub release (see [The 
archives](#the-archives)).
+
+   `--dry-run` packages without committing to a release, to inspect `dist/`:
+   it neither stops nor asks, but lists at the end everything a real release
+   would stop on or ask about (exiting with 1 if it would stop), and nothing
+   goes to `releases/`.
 
 3. Publish:
 
@@ -47,19 +53,24 @@
    a link to `x86_64-unknown-linux-gnu`, for third-party install scripts using
    that old path.
 
-   It then creates and pushes the `v<version>` tag on HEAD, and runs
-   `website/deploy.sh`, last, so the site never describes a version whose
-   binaries aren't online yet.
+   It then creates and pushes the `v<version>` tag on HEAD, runs
+   `github-release.sh`, and runs `website/deploy.sh`, last, so the site never
+   describes a version whose binaries aren't online yet.
+
+   `github-release.sh` creates the GitHub release from that tag as a **draft**,
+   with the files of `dist/` and the version's `CHANGELOG.md` section as notes;
+   when the release already exists, its files are replaced instead. If it 
fails,
+   `deploy.sh` warns and goes on with the website: fix the cause, then run
+   `./build-scripts/github-release.sh` alone. It refuses a dry run's `dist/`.
 
-4. Create the GitHub release from that tag, not from a branch:
+   Both need `gh`, logged in to github.com (`gh auth login`); `deploy.sh` 
checks
+   it before building.
 
-       gh release create v<version> dysk_<version>.zip --verify-tag
-
-   With "target: main" in the GitHub form, the tag lands on whatever `main` is
-   on GitHub at that moment.
+4. Review the draft on GitHub, edit its notes if needed, and publish it.
 
 Staging and deploy settings come from `build-scripts/_local.sh` (see below).
-Without it, `release.sh` builds locally on a single host and `deploy.sh` won't 
run.
+Without it, `release.sh` builds locally on a single host, which must be a Mac,
+and `deploy.sh` won't run.
 
 A dirty tree doesn't block either step, but both ask first: 
`build-all-targets.sh`
 offers to stage it anyway (recorded in a `<version>-<commit>.dirty` marker 
beside
@@ -96,7 +107,7 @@
 duplicate linked dylib, and a binary built for the host triple is run to 
confirm
 it reports the current version.
 
-## The archive
+## The archives
 
 `dysk_<version>.zip` holds the contents of `build/`, with no wrapping 
directory:
 
@@ -114,6 +125,18 @@
 script `OUT_DIR`; `build-all-targets.sh` finds that directory under
 `.build-cache/` and copies them out.
 
+`dist/` holds exactly the files of the GitHub release: that zip, one archive 
per
+target, and `sha256sums`. Each per-target archive is a `.tar.gz` (a `.zip` for
+Windows) unpacking to a single directory:
+
+    dysk-v<version>-<triple>/
+        dysk                         dysk.exe on Windows
+        completion/
+        man/dysk.1
+        CHANGELOG.md
+        LICENSE
+        README.md
+
 ## Machine-local config (`_local.sh`)
 
 `build-scripts/_local.sh` holds per-machine settings and is **gitignored**, so 
it
@@ -144,5 +167,7 @@
 
 - `build-target.sh <filter>` — build one target (`--list` to see them), e.g.
   `./build-scripts/build-target.sh aarch64-apple-darwin`
+- `github-release.sh` — create the draft GitHub release from `dist/` (run by
+  `deploy.sh`)
 - `version.sh`, `target.sh` — print the dysk version and the host triple
 - `_common.sh`, `_targets.sh` — sourced libraries, not run directly
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/build-scripts/_common.sh 
new/dysk-3.8.2/build-scripts/_common.sh
--- old/dysk-3.8.1/build-scripts/_common.sh     2026-10-17 18:04:08.000000000 
+0200
+++ new/dysk-3.8.2/build-scripts/_common.sh     2026-11-02 17:55:48.000000000 
+0100
@@ -77,6 +77,15 @@
 
 release_tag() { printf 'v%s\n' "$(dysk_version)"; }
 
+DYSK_GITHUB_REPO=Canop/dysk
+
+# Die unless gh is installed and logged in to github.com.
+need_gh() {
+    need gh "see https://cli.github.com";
+    gh auth status --hostname github.com >/dev/null 2>&1 \
+        || die "gh isn't logged in to github.com — run 'gh auth login'"
+}
+
 # The commit the tag points at on origin, peeled; empty when there's no such 
tag.
 remote_tag_commit() { # remote_tag_commit <tag>
     local refs peeled
@@ -141,6 +150,15 @@
     ok "tag $tag created and pushed"
 }
 
+# The body of the CHANGELOG.md section of a version, without its heading.
+changelog_notes() { # changelog_notes <version>
+    awk -v h="### v$1" '
+        $0 == h || index($0, h " ") == 1 { on = 1; next }
+        on && (/^### / || /^<a name=/) { exit }
+        on { print }
+    ' CHANGELOG.md
+}
+
 # Ask a yes/no question on the terminal, defaulting to no. With no terminal to
 # ask on (cron, CI) the answer is no, unless DYSK_YES is set.
 confirm() { # confirm <question>
@@ -171,6 +189,12 @@
     rsync -az "$dir"/ "$DYSK_STAGE_HOST:$dest/"
 }
 
+# Prints yes or no; fails when the server can't be reached.
+stage_has_release() {
+    ssh "$DYSK_STAGE_HOST" \
+        "if test -d '$DYSK_STAGE_DIR/$(release_id)'; then echo yes; else echo 
no; fi"
+}
+
 stage_fetch() { # stage_fetch <local-dir>  <- pulls <dir>/<id>/ into it
     local dir=$1 id
     id=$(release_id)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/build-scripts/deploy.sh 
new/dysk-3.8.2/build-scripts/deploy.sh
--- old/dysk-3.8.1/build-scripts/deploy.sh      2026-10-17 18:04:08.000000000 
+0200
+++ new/dysk-3.8.2/build-scripts/deploy.sh      2026-11-02 17:55:48.000000000 
+0100
@@ -1,7 +1,8 @@
 #!/usr/bin/env bash
 #
-# Build the release and rsync it into the download directory on the server, 
then
-# deploy the website.
+# Build the release, rsync it into the download directory on the server, tag 
it,
+# create the GitHub release as a draft (github-release.sh), then deploy the
+# website.
 #
 # Nothing goes through ~/dev/www/dystroy: that tree is a per-machine mirror of
 # the whole site, so pushing it from one machine republishes stale copies of
@@ -17,8 +18,9 @@
 # A mistyped destination would spray the binaries over the site itself.
 [[ $DYSK_DEPLOY_TARGET == *:*/dysk/download ]] \
     || die "DYSK_DEPLOY_TARGET should end in /dysk/download, got 
'$DYSK_DEPLOY_TARGET'"
+need_gh
 
-# build the release zip (and the build/ directory)
+# build the release zip, the build/ directory, and dist/
 "$here/release.sh"
 
 version=$(dysk_version)
@@ -39,6 +41,12 @@
 h2 "Tagging the release"
 push_release_tag
 
+# A draft, to be reviewed and published in GitHub's interface. A failure there
+# doesn't hold back the website.
+if ! "$here/github-release.sh"; then
+    warn "the GitHub release failed — once fixed, run 
./build-scripts/github-release.sh"
+fi
+
 # Last: the site must never describe a version whose binaries aren't up yet.
 h2 "Deploying the website"
 "$here/../website/deploy.sh"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/build-scripts/github-release.sh 
new/dysk-3.8.2/build-scripts/github-release.sh
--- old/dysk-3.8.1/build-scripts/github-release.sh      1970-01-01 
01:00:00.000000000 +0100
+++ new/dysk-3.8.2/build-scripts/github-release.sh      2026-11-02 
17:55:48.000000000 +0100
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+#
+# Create the GitHub release of the current version as a draft, with the files
+# of dist/ and the version's CHANGELOG.md section as notes. When the release
+# already exists, its files are replaced instead.
+#
+# Run by deploy.sh after the tag is pushed; run it again by hand if it failed
+# there. It needs the dist/ of a real (not dry) release.sh run.
+
+source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
+
+need_gh
+
+version=$(dysk_version)
+tag=$(release_tag)
+
+[[ -f "dist/dysk_$version.zip" ]] || die "no dist/ for $version — run 
release.sh first"
+[[ ! -e dist/.dry-run ]] || die "dist/ comes from a dry run — run release.sh 
without --dry-run"
+assets=(dist/*.tar.gz dist/*.zip dist/sha256sums)
+
+h2 "Creating the GitHub release $tag"
+tags=$(gh release list --repo "$DYSK_GITHUB_REPO" --limit 1000 --json tagName 
--jq '.[].tagName')
+if grep -qxF "$tag" <<< "$tags"; then
+    gh release upload "$tag" "${assets[@]}" --repo "$DYSK_GITHUB_REPO" 
--clobber
+    ok "files of the existing release $tag replaced"
+else
+    notes=$(mktemp)
+    changelog_notes "$version" > "$notes"
+    [[ -s $notes ]] || warn "no section for v$version in CHANGELOG.md, the 
release notes are empty"
+    gh release create "$tag" "${assets[@]}" --repo "$DYSK_GITHUB_REPO" \
+        --verify-tag --draft --title "dysk $tag" --notes-file "$notes"
+    rm -f "$notes"
+    ok "draft release $tag created with ${#assets[@]} files — review and 
publish it on GitHub"
+fi
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/build-scripts/release.sh 
new/dysk-3.8.2/build-scripts/release.sh
--- old/dysk-3.8.1/build-scripts/release.sh     2026-10-17 18:04:08.000000000 
+0200
+++ new/dysk-3.8.2/build-scripts/release.sh     2026-11-02 17:55:48.000000000 
+0100
@@ -1,36 +1,86 @@
 #!/usr/bin/env bash
 #
-# Assemble and package a full dysk release: build/ plus dysk_<version>.zip in 
the
-# repo root (the zip is both a download and the artifact to attach to the 
GitHub
-# release), with a copy kept in releases/.
+# Assemble and package a full dysk release: build/, dysk_<version>.zip in the
+# repo root (the download served on dystroy.org), with a copy kept in 
releases/,
+# and dist/, which holds exactly the files to attach to the GitHub release: one
+# archive per target, the global zip, and sha256sums.
 #
 # With staging configured (DYSK_STAGE_HOST, see _local.sh) this does NOT build 
—
 # it fetches the artifacts every host pushed for the current commit and checks
 # the set is complete (so the macOS binaries from the Mac and the linux/windows
 # ones from wherever they were built come together). Without staging it falls
 # back to a local build-all (single host). Either way it then verifies every
-# binary and zips.
+# binary and packages.
+#
+# --dry-run packages for inspection: it neither stops nor asks, but lists at 
the
+# end everything a real release would stop on or ask about, and archives 
nothing
+# in releases/. It exits with 1 when a real release would stop.
 
 source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_targets.sh"
 here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 
+dry_run=no
+case ${1:-} in
+    "") ;;
+    --dry-run|-n) dry_run=yes ;;
+    *) die "usage: $(basename "$0") [--dry-run]" ;;
+esac
+[[ $# -le 1 ]] || die "usage: $(basename "$0") [--dry-run]"
+
+stops=()
+asks=()
+# Die, or in a dry run note it and go on.
+stop() { # stop <message>
+    [[ $dry_run == yes ]] || die "$1"
+    warn "$1"
+    stops+=("$1")
+}
+# Ask, or in a dry run note the question and go on.
+ask() { # ask <question> <abort message>
+    if [[ $dry_run == yes ]]; then
+        asks+=("$1")
+        return 0
+    fi
+    confirm "$1" || die "$2"
+}
+
 version=$(dysk_version)
+[[ $dry_run == yes ]] && h1 "DRY RUN — packaging $version for inspection"
 
 # The GitHub release and the source archives come from origin, so the binaries
 # must be built from the commit that's there.
 h2 "Checking the release source"
-check_release_source
+if [[ $dry_run == yes ]]; then
+    # A separate process, so that errexit applies inside the check, and no ERR
+    # trap here, as bash 3.2 runs it even in a condition.
+    if out=$(trap - ERR; "$BASH" -c 'source "$1"; check_release_source' _ 
"$here/_common.sh" 2>&1); then
+        printf '%s\n' "$out"
+    else
+        reason=
+        while IFS= read -r line; do
+            if [[ $line == "✗ "* ]]; then
+                reason=${line#"✗ "}
+                break
+            fi
+        done <<< "$out"
+        stop "${reason:-the release source check failed}"
+    fi
+else
+    check_release_source
+fi
 
 # Publishing a binary built from uncommitted work is worth a question, whether
 # or not the artifacts travelled through a staging server.
 if ! tree_is_clean; then
     warn "working tree has uncommitted changes, so this release won't match 
commit $(git rev-parse --short HEAD)"
-    confirm "assemble it anyway?" || die "aborted — commit your changes, then 
re-run"
+    ask "uncommitted changes — assemble it anyway?" "aborted — commit your 
changes, then re-run"
 fi
 
 if staging_configured; then
     id=$(release_id)
     h1 "Assembling release $version from $DYSK_STAGE_HOST ($id)"
+    staged=$(stage_has_release) || die "couldn't reach $DYSK_STAGE_HOST"
+    [[ $staged == yes ]] || die "nothing staged for $id — run 
build-all-targets.sh first"
     rm -rf build && mkdir build
     h2 "Fetching staged artifacts"
     stage_fetch build
@@ -43,19 +93,19 @@
         while IFS= read -r line; do
             [[ -n $line ]] && info "$line"
         done <<< "$dirty_report"
-        confirm "package them anyway?" || die "aborted — rebuild those targets 
from a clean tree"
+        ask "staged artifacts built from uncommitted changes — package them 
anyway?" \
+            "aborted — rebuild those targets from a clean tree"
     fi
-    manifest_fn=all_release_targets   # a full release must contain every 
target
 else
-    h1 "Building release $version locally — THIS host's targets only"
+    h1 "Building release $version locally"
     # A Mac covers the whole matrix (zig for linux/windows, native for Apple);
-    # elsewhere the Apple binaries are missing, and that's worth saying.
+    # elsewhere the Apple binaries are missing, which only a dry run accepts.
     if [[ $(all_targets | wc -l) -lt $(all_release_targets | wc -l) ]]; then
-        warn "this host can't build every release target (no Apple binary 
outside a Mac)."
-        warn "to assemble a full release, set DYSK_STAGE_HOST in 
build-scripts/_local.sh on each machine."
+        hint="this host can't build every release target (no Apple binary 
outside a Mac) — set DYSK_STAGE_HOST in build-scripts/_local.sh on each machine"
+        [[ $dry_run == yes ]] || die "$hint"
+        warn "$hint"
     fi
     "$here/build-all-targets.sh"
-    manifest_fn=all_targets           # can only expect what this host builds
 fi
 
 # Completeness: every target of the full (cross-host) release manifest must be
@@ -69,15 +119,23 @@
     else
         missing+=("$label ($triple)")
     fi
-done < <("$manifest_fn")
-[[ ${#missing[@]} -eq 0 ]] || die "release incomplete — missing binaries: 
${missing[*]}"
-ok "all release targets present and verified"
+done < <(all_release_targets)
+if [[ ${#missing[@]} -gt 0 ]]; then
+    stop "release incomplete — missing binaries: ${missing[*]}"
+else
+    ok "all release targets present and verified"
+fi
 
 # The non-binary artifacts must be there too.
+missing=()
 for f in README.md CHANGELOG.md version install.md completion man/dysk.1; do
-    [[ -e "build/$f" ]] || die "release artifact missing from build/: $f"
+    [[ -e "build/$f" ]] || missing+=("$f")
 done
-ok "metadata, completions and man page present"
+if [[ ${#missing[@]} -gt 0 ]]; then
+    stop "release artifacts missing from build/: ${missing[*]}"
+else
+    ok "metadata, completions and man page present"
+fi
 
 # build the release archive, whose entries are the contents of build/, not a
 # build/ directory: dysk_3.6.1.zip unzips to aarch64-apple-darwin/, man/, etc.
@@ -85,10 +143,60 @@
 ( cd build && zip -rq "../dysk_$version.zip" -- * )
 ok "created dysk_$version.zip"
 
+# One archive per target, each unpacking to a single dysk-v<version>-<triple>/
+# directory: a .zip for Windows, a .tar.gz elsewhere.
+h2 "Packaging one archive per target in dist/"
+rm -rf dist && mkdir -p dist/.tmp
+while IFS='|' read -r label triple _tool _features; do
+    bin=$(target_binary "$triple")
+    [[ -f $bin ]] || continue
+    name="$NAME-v$version-${triple%%.*}"
+    mkdir "dist/.tmp/$name"
+    cp "$bin" "dist/.tmp/$name/"
+    for f in build/README.md build/CHANGELOG.md LICENSE build/completion 
build/man; do
+        if [[ -e $f ]]; then
+            cp -R "$f" "dist/.tmp/$name/"
+        fi
+    done
+    if [[ $triple == *windows* ]]; then
+        ( cd dist/.tmp && zip -rqX "../$name.zip" "$name" )
+        ok "dist/$name.zip"
+    else
+        # keep macOS metadata (._ files, xattrs) out of the archive
+        COPYFILE_DISABLE=1 tar --no-xattrs -czf "dist/$name.tar.gz" -C 
dist/.tmp "$name"
+        ok "dist/$name.tar.gz"
+    fi
+done < <(all_release_targets)
+rm -rf dist/.tmp
+cp "dysk_$version.zip" dist/
+sha256() { if have sha256sum; then sha256sum "$@"; else shasum -a 256 "$@"; 
fi; }
+( cd dist && sha256 *.tar.gz *.zip > sha256sums )
+ok "dist/sha256sums"
+
+if [[ $dry_run == yes ]]; then
+    # github-release.sh refuses to publish a dry run's dist/
+    touch dist/.dry-run
+    h1 "DRY RUN done — inspect dist/ (nothing archived in releases/)"
+    if [[ ${#stops[@]} -gt 0 ]]; then
+        warn "a real release would stop:"
+        for s in "${stops[@]}"; do info "- $s"; done
+    fi
+    if [[ ${#asks[@]} -gt 0 ]]; then
+        warn "a real release would ask:"
+        for a in "${asks[@]}"; do info "- $a"; done
+    fi
+    [[ ${#stops[@]} -eq 0 ]] || exit 1
+    if [[ ${#asks[@]} -gt 0 ]]; then
+        ok "a real release would go through, once those questions are answered"
+    else
+        ok "a real release would go through"
+    fi
+    exit 0
+fi
+
 mkdir -p releases
 cp "dysk_$version.zip" releases/
 ok "archived in releases/"
 
-h1 "Release $version ready: dysk_$version.zip"
-info "deploy.sh publishes it and tags $(release_tag); then create the GitHub 
release with"
-info "    gh release create $(release_tag) dysk_$version.zip --verify-tag"
+h1 "Release $version ready: dysk_$version.zip and dist/"
+info "deploy.sh publishes it, tags $(release_tag), and creates the GitHub 
release as a draft"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/cli/Cargo.toml 
new/dysk-3.8.2/cli/Cargo.toml
--- old/dysk-3.8.1/cli/Cargo.toml       2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/cli/Cargo.toml       2026-11-02 17:55:48.000000000 +0100
@@ -1,6 +1,6 @@
 [package]
 name = "dysk-cli"
-version = "3.8.1"
+version = "3.8.2"
 authors = ["dystroy <[email protected]>"]
 edition = "2024"
 license = "MIT"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/cli/src/csv.rs 
new/dysk-3.8.2/cli/src/csv.rs
--- old/dysk-3.8.1/cli/src/csv.rs       2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/cli/src/csv.rs       2026-11-02 17:55:48.000000000 +0100
@@ -2,6 +2,7 @@
     crate::{
         Args,
         col::Col,
+        sanitize::sanitize,
     },
     lfs_core::*,
     std::{
@@ -27,7 +28,8 @@
         &mut self,
         content: D,
     ) -> Result<(), std::io::Error> {
-        let s = content.to_string();
+        let raw = content.to_string();
+        let s = sanitize(&raw);
         let needs_quotes = s.contains(self.separator) || s.contains('"') || 
s.contains('\n');
         if needs_quotes {
             write!(self.w, "\"")?;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/cli/src/lib.rs 
new/dysk-3.8.2/cli/src/lib.rs
--- old/dysk-3.8.1/cli/src/lib.rs       2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/cli/src/lib.rs       2026-11-02 17:55:48.000000000 +0100
@@ -9,6 +9,7 @@
 pub mod list_cols;
 pub mod normal;
 pub mod order;
+pub mod sanitize;
 pub mod sorting;
 pub mod table;
 pub mod timeout;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/cli/src/sanitize.rs 
new/dysk-3.8.2/cli/src/sanitize.rs
--- old/dysk-3.8.1/cli/src/sanitize.rs  1970-01-01 01:00:00.000000000 +0100
+++ new/dysk-3.8.2/cli/src/sanitize.rs  2026-11-02 17:55:48.000000000 +0100
@@ -0,0 +1,196 @@
+use std::borrow::Cow;
+
+/// Replace C0 (including ESC), DEL, and C1 controls with a visible `\xNN`
+/// escape (e.g. `\x1b`), for strings which may be printed to a terminal
+/// and may contain untrusted bytes.
+pub fn sanitize(s: &str) -> Cow<'_, str> {
+    if !s.contains(char::is_control) {
+        return Cow::Borrowed(s);
+    }
+    let mut out = String::with_capacity(s.len() + 8);
+    for c in s.chars() {
+        if c.is_control() {
+            // C0, DEL and C1 are all below U+0100.
+            out.push_str(&format!("\\x{:02x}", c as u32));
+        } else {
+            out.push(c);
+        }
+    }
+    Cow::Owned(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use {
+        super::*,
+        crate::{
+            args::Args,
+            csv,
+            json,
+            table,
+            units::Units,
+        },
+        clap::Parser,
+        lfs_core::*,
+        std::io::Cursor,
+    };
+
+    #[test]
+    fn keeps_text_without_controls() {
+        assert_eq!(sanitize("/dev/sda1"), "/dev/sda1");
+        assert_eq!(sanitize("café /mnt"), "café /mnt");
+    }
+
+    #[test]
+    fn replaces_c0_del_c1_and_esc() {
+        // ESC, BEL, DEL, and a C1 CSI.
+        assert_eq!(sanitize("a\u{1b}\u{7}\u{7f}\u{9b}b"), 
"a\\x1b\\x07\\x7f\\x9bb");
+        assert!(!sanitize("a\u{1b}b").contains(char::is_control));
+    }
+
+    #[test]
+    fn table_and_csv_are_neutralized_json_is_not() {
+        let evil = mount("fuse\u{1b}[31msrc", "/mnt/\u{1b}]0;pwned\u{7}", 
true);
+        let clean = mount("/dev/sda1", "/safe/path", false);
+        let mounts = [&evil, &clean];
+
+        // One column at a time so termimad's terminal-width wrapping cannot
+        // split the sanitized text across lines.
+        assert_col("fs", &mounts, &["fuse\\x1b[31msrc", "/dev/sda1"]);
+        assert_col("mp", &mounts, &["/mnt/\\x1b]0;pwned\\x07", "/safe/path"]);
+        assert_col("type", &mounts, &["fu\\x9bse", "ext4"]);
+        assert_col("label", &mounts, &["lab\\x9del"]);
+        assert_col("options", &mounts, &["rw,compress=zstd\\x1b:3"]);
+        assert_col("uuid", &mounts, &["uu\\x7fid"]);
+        assert_col("partuuid", &mounts, &["part\\x1b"]);
+        assert_col("compress", &mounts, &["zstd\\x1b:3"]);
+
+        let colored = render_table("mp", &mounts, true);
+        assert!(
+            !colored.contains("\u{1b}]0;pwned"),
+            "OSC payload survived color output: {colored:?}"
+        );
+        assert!(!colored.contains('\u{7}'), "{colored:?}");
+        assert!(colored.contains("/mnt/\\x1b]0;pwned\\x07"), "{colored:?}");
+
+        let args = 
args_with("fs+type+label+mp+options+uuid+partuuid+compress");
+        let mut csv_buf = Cursor::new(Vec::new());
+        csv::write(&mut csv_buf, &mounts, &args).unwrap();
+        let csv_out = String::from_utf8(csv_buf.into_inner()).unwrap();
+        assert_terminal_safe(&csv_out);
+        for needle in [
+            "/mnt/\\x1b]0;pwned\\x07",
+            "fuse\\x1b[31msrc",
+            "fu\\x9bse",
+            "lab\\x9del",
+            "rw,compress=zstd\\x1b:3",
+            "uu\\x7fid",
+            "part\\x1b",
+            "zstd\\x1b:3",
+            "/safe/path",
+            "/dev/sda1",
+        ] {
+            assert!(csv_out.contains(needle), "csv missing {needle:?}: 
{csv_out:?}");
+        }
+
+        let json_out = serde_json::to_string(&json::output_value(&mounts, 
Units::Si)).unwrap();
+        // serde escapes C0 (so the ESC byte is gone) and keeps the original 
text.
+        assert!(json_out.contains(r"\u001b"), "{json_out}");
+        assert!(json_out.contains("]0;pwned"), "{json_out}");
+        assert!(json_out.contains("/safe/path"), "{json_out}");
+        assert!(json_out.contains("lab\u{9d}el"), "{json_out}");
+        assert!(!json_out.contains("/mnt/\\x1b]0;pwned\\x07"), "{json_out}");
+        assert!(!json_out.contains('\u{1b}'), "{json_out}");
+    }
+
+    fn assert_col(
+        col: &str,
+        mounts: &[&Mount],
+        needles: &[&str],
+    ) {
+        let out = render_table(col, mounts, false);
+        assert_terminal_safe(&out);
+        for needle in needles {
+            assert!(
+                out.contains(needle),
+                "{col} missing {needle:?} in {out:?}"
+            );
+        }
+    }
+
+    fn render_table(
+        cols: &str,
+        mounts: &[&Mount],
+        color: bool,
+    ) -> String {
+        let mut buf = Cursor::new(Vec::new());
+        table::write(&mut buf, mounts, color, &args_with(cols)).unwrap();
+        String::from_utf8(buf.into_inner()).unwrap()
+    }
+
+    fn args_with(cols: &str) -> Args {
+        Args::try_parse_from(["dysk", "--color", "no", "--ascii", "-c", 
cols]).unwrap()
+    }
+
+    fn assert_terminal_safe(out: &str) {
+        for c in ['\u{1b}', '\u{7}', '\u{7f}', '\u{9d}', '\u{9b}'] {
+            assert!(!out.contains(c), "output contains {c:?}: {out:?}");
+        }
+    }
+
+    fn mount(
+        fs: &str,
+        mount_point: &str,
+        hostile: bool,
+    ) -> Mount {
+        let (label, options, uuid, part_uuid, fs_type) = if hostile {
+            (
+                Some("lab\u{9d}el".to_string()),
+                vec![
+                    MountOption::new("rw", None),
+                    MountOption::new("compress", Some("zstd\u{1b}:3")),
+                ],
+                Some("uu\u{7f}id".to_string()),
+                Some("part\u{1b}".to_string()),
+                "fu\u{9b}se".to_string(),
+            )
+        } else {
+            (
+                None,
+                vec![MountOption::new("rw", None)],
+                None,
+                None,
+                "ext4".to_string(),
+            )
+        };
+        Mount {
+            info: MountInfo {
+                id: Some(1),
+                parent: Some(0),
+                dev: device_id(),
+                root: "/".into(),
+                mount_point: mount_point.into(),
+                options,
+                fs: fs.to_string(),
+                fs_type,
+                bound: false,
+            },
+            fs_label: label,
+            disk: None,
+            stats: Err(StatsError::Excluded),
+            uuid,
+            part_uuid,
+        }
+    }
+
+    fn device_id() -> DeviceId {
+        #[cfg(windows)]
+        {
+            DeviceId::new(0x1234_5678)
+        }
+        #[cfg(not(windows))]
+        {
+            DeviceId::new(8, 16)
+        }
+    }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/cli/src/table.rs 
new/dysk-3.8.2/cli/src/table.rs
--- old/dysk-3.8.1/cli/src/table.rs     2026-10-17 18:04:08.000000000 +0200
+++ new/dysk-3.8.2/cli/src/table.rs     2026-11-02 17:55:48.000000000 +0100
@@ -2,6 +2,7 @@
     crate::{
         Args,
         col::Col,
+        sanitize::sanitize,
     },
     lfs_core::*,
     std::{
@@ -60,16 +61,22 @@
                     .map_or(String::new(), |i| i.to_string()),
             )
             .set("dev", mount.info.dev)
-            .set("filesystem", &mount.info.fs)
+            .set("filesystem", sanitize(&mount.info.fs))
             .set("disk", mount.disk.as_ref().map_or("", |d| d.disk_type()))
-            .set("type", &mount.info.fs_type)
-            .set("mount-point", mount.info.mount_point.to_string_lossy())
-            .set("mount-options", mount.info.options_string())
-            .set_option("uuid", mount.uuid.as_ref())
-            .set_option("part_uuid", mount.part_uuid.as_ref())
-            .set_option("compress-level", mount.info.option_value("compress"));
+            .set("type", sanitize(&mount.info.fs_type))
+            .set(
+                "mount-point",
+                sanitize(&mount.info.mount_point.to_string_lossy()),
+            )
+            .set("mount-options", sanitize(&mount.info.options_string()))
+            .set_option("uuid", mount.uuid.as_deref().map(sanitize))
+            .set_option("part_uuid", mount.part_uuid.as_deref().map(sanitize))
+            .set_option(
+                "compress-level",
+                mount.info.option_value("compress").map(sanitize),
+            );
         if let Some(label) = &mount.fs_label {
-            sub.set("label", label);
+            sub.set("label", sanitize(label));
         }
         if mount.is_remote() {
             sub.set("remote", "x");
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dysk-3.8.1/website/src/install.md 
new/dysk-3.8.2/website/src/install.md
--- old/dysk-3.8.1/website/src/install.md       2026-10-17 18:04:08.000000000 
+0200
+++ new/dysk-3.8.2/website/src/install.md       2026-11-02 17:55:48.000000000 
+0100
@@ -25,10 +25,17 @@
 The archives also contain dysk's man page and shell completion scripts.
 
 
-You may download previous releases on [GitHub 
releases](https://github.com/Canop/dysk/releases).
+[GitHub releases](https://github.com/Canop/dysk/releases) hold previous 
releases too, and, for each release, one archive per target (eg 
`dysk-v3.8.1-x86_64-unknown-linux-musl.tar.gz`) with a `sha256sums` file.
 
 When you download executable files, you'll have to ensure the shell can find 
them. An easy solution on linux is for example to put them in `/usr/local/bin`. 
You may also have to set them executable using `chmod +x dysk`.
 
+On Linux or macOS, typical destinations for the other files of the archive are:
+
+* `man/dysk.1` -> `/usr/local/share/man/man1/`
+* `completion/dysk.bash` -> `/usr/local/share/bash-completion/completions/dysk`
+* `completion/_dysk` -> `/usr/local/share/zsh/site-functions/`
+* `completion/dysk.fish` -> `~/.config/fish/completions/`
+
 # From crates.io
 
 You'll need to have the [Rust development environment](https://www.rustup.rs) 
installed and up to date.

++++++ dysk.obsinfo ++++++
--- /var/tmp/diff_new_pack.6dP1Iz/_old  2026-11-05 18:23:35.690899371 +0100
+++ /var/tmp/diff_new_pack.6dP1Iz/_new  2026-11-05 18:23:35.706900038 +0100
@@ -1,5 +1,5 @@
 name: dysk
-version: 3.8.1
-mtime: 1792253048
-commit: c002b844cfc33d56aaa7a4c46b80b033b07e0dee
+version: 3.8.2
+mtime: 1793638548
+commit: 990df3edc2a1312d2f943064f605025a45691c55
 

++++++ vendor.tar.zst ++++++
/work/SRC/openSUSE:Factory/dysk/vendor.tar.zst 
/work/SRC/openSUSE:Factory/.dysk.new.3759290/vendor.tar.zst differ: char 7, 
line 1

Reply via email to