Public Access
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.
◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.
ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.
So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.
Three details that are load-bearing rather than incidental:
- The comparison is rpmvercmp and not SemVer, because rpm is what will accept
or refuse the install. Same argument ADR 0014 makes for versionCode. The
installed side is read out of the rpm database rather than derived from the
assembly, so the SemVer-to-rpm translation exists in exactly one place. All
28 expected values in RpmVersionTests came out of rpm itself rather than out
of a reading of the algorithm.
- The digest is checked and is not a signature. Velopack verifies its own
downloads and Android checks a signature; an RPM over HTTPS has neither. The
manifest names a SHA-256 and a mismatch deletes the file rather than leaving
something installable-by-hand in the profile directory.
- ONLY_TRUSTED is cleared, which is what routes the request to polkit's
package-install-untrusted action. The package is unsigned, so requiring
trust would fail every update at the last step.
◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.
ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.
The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.
This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.
Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.
Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.
◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.
This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.
TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.
The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.
End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.
Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
365 lines
17 KiB
Bash
Executable File
365 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
|
|
#
|
|
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
|
|
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
|
|
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
|
|
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
|
|
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
|
|
#
|
|
# It is also runnable at a desk, which is the point of it being a script:
|
|
#
|
|
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
|
# --configuration Release --runtime linux-x64 --self-contained true \
|
|
# -p:RestoreLockedMode=false --output /tmp/linux-x64
|
|
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
|
|
#
|
|
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
|
|
# ADR 0013 rule 3 is what decides which channel may do it.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
payload=""
|
|
version=""
|
|
channel="release"
|
|
out=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--payload) payload="$2"; shift 2 ;;
|
|
--version) version="$2"; shift 2 ;;
|
|
--channel) channel="$2"; shift 2 ;;
|
|
--out) out="$2"; shift 2 ;;
|
|
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
for required in payload version out; do
|
|
if [ -z "${!required}" ]; then
|
|
echo "--$required is required." >&2
|
|
exit 2
|
|
fi
|
|
done
|
|
|
|
case "$channel" in
|
|
release|nightly) ;;
|
|
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
|
|
esac
|
|
|
|
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
|
|
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
|
|
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
|
|
if [ ! -s "$payload/DodoSSH" ]; then
|
|
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
|
|
ls -la "$payload" >&2 || true
|
|
exit 1
|
|
fi
|
|
|
|
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
|
|
#
|
|
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
|
|
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
|
|
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
|
|
# have two identical launchers and no way to tell which was which.
|
|
#
|
|
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
|
|
# — is not here and must not be, because it belongs to the build rather than to the package: the
|
|
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
|
|
# Doing it here as well would be a second answer to a question that already has one.
|
|
if [ "$channel" = nightly ]; then
|
|
name=dodossh-nightly
|
|
dir=DodoSSH.Nightly
|
|
bin=dodossh-nightly
|
|
title="DodoSSH Nightly"
|
|
else
|
|
name=dodossh
|
|
dir=DodoSSH
|
|
bin=dodossh
|
|
title="DodoSSH"
|
|
fi
|
|
|
|
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
|
|
#
|
|
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
|
|
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
|
|
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
|
|
#
|
|
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
|
|
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
|
|
# rising within a floored version and the first real tag moves past all of them, which is the same
|
|
# ordering property the Velopack channels have and is worth having on both.
|
|
#
|
|
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
|
|
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
|
|
# rule for into the field ordering is done on.
|
|
rpm_version="${version%%+*}"
|
|
rpm_version="${rpm_version/-/\~}"
|
|
|
|
case "$rpm_version" in
|
|
*-*)
|
|
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
|
|
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
|
|
# only ever one packaging here — the version already carries the build height, because upstream and
|
|
# packager are the same repository.
|
|
rpm_release=1
|
|
|
|
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
|
|
|
|
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
|
|
#
|
|
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
|
|
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
|
|
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
|
|
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
|
|
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
|
|
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
|
|
# the android job for the hour that cost.
|
|
if ! command -v rpmbuild >/dev/null 2>&1; then
|
|
SUDO=""
|
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
|
|
|
echo "==> installing rpm"
|
|
if command -v apk >/dev/null 2>&1; then
|
|
$SUDO apk add --no-cache rpm
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
$SUDO dnf install -y rpm-build
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
$SUDO apt-get update -qq
|
|
$SUDO apt-get install -y --no-install-recommends rpm
|
|
else
|
|
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
|
|
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
rpmbuild --version
|
|
|
|
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
|
|
#
|
|
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
|
|
# libraries there are this application's private copies and have no business satisfying anybody
|
|
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
|
|
# library that links another bundled library asks for a soname nothing provides and the package
|
|
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
|
|
# does on every publish.
|
|
#
|
|
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
|
|
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
|
|
# going stale in the direction that produces a package nobody can install.
|
|
#
|
|
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
|
|
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
|
|
#
|
|
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
|
|
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
|
|
# exclusion and put the uninstallable package back.
|
|
bundled="$(
|
|
for library in "$payload"/lib*.so*; do
|
|
[ -f "$library" ] && basename "$library"
|
|
done |
|
|
sed 's/\./\\./g' |
|
|
sort -u |
|
|
paste -sd '|' -
|
|
)"
|
|
|
|
if [ -z "$bundled" ]; then
|
|
echo "There are no native libraries at the top of $payload." >&2
|
|
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
|
|
echo "rather than a package with nothing to exclude." >&2
|
|
exit 1
|
|
fi
|
|
|
|
work="$(mktemp -d)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
|
|
mkdir -p "$out"
|
|
|
|
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
|
|
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
|
|
# an aarch64 runner would silently make them disagree.
|
|
#
|
|
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
|
|
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
|
|
# be both meaningless and, if rpm guessed something plausible, actively misleading.
|
|
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
|
|
--target x86_64 \
|
|
--define "_topdir $work" \
|
|
--define "_rpmdir $out" \
|
|
--define "dist %{nil}" \
|
|
--define "dodo_name $name" \
|
|
--define "dodo_version $rpm_version" \
|
|
--define "dodo_release $rpm_release" \
|
|
--define "dodo_dir $dir" \
|
|
--define "dodo_bin $bin" \
|
|
--define "dodo_title $title" \
|
|
--define "dodo_payload $payload" \
|
|
--define "dodo_bundled $bundled" \
|
|
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
|
|
--define "dodo_licence $repo_root/LICENSE"
|
|
|
|
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
|
|
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
|
|
if [ ! -s "$rpm_file" ]; then
|
|
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
|
|
find "$out" >&2 || true
|
|
exit 1
|
|
fi
|
|
mv "$rpm_file" "$out/"
|
|
rmdir "$out/x86_64" 2>/dev/null || true
|
|
rpm_file="$out/$(basename "$rpm_file")"
|
|
|
|
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
|
|
|
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
|
|
#
|
|
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
|
# that happens are visible in an exit code. Each check below is one of them.
|
|
|
|
requires="$(rpm -qp --requires "$rpm_file")"
|
|
provides="$(rpm -qp --provides "$rpm_file")"
|
|
files="$(rpm -qp --list "$rpm_file")"
|
|
|
|
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
|
|
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
|
|
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
|
|
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
|
|
# the actual contents rather than assumed.
|
|
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
|
unsatisfiable=""
|
|
for soname in $carried; do
|
|
if printf '%s\n' "$requires" | grep -qF "$soname("; then
|
|
unsatisfiable="$unsatisfiable $soname"
|
|
fi
|
|
done
|
|
if [ -n "$unsatisfiable" ]; then
|
|
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
|
|
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
|
|
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
|
|
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
|
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
|
# the macro would break it silently and in the direction nobody looks.
|
|
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
|
|
echo "This package advertises bundled libraries to the system:" >&2
|
|
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
|
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
|
|
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
|
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
|
# self-contained .NET tree cannot fail to have.
|
|
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
|
|
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
|
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
|
printf '%s\n' "$requires" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
|
|
# entries and its absence would be caught by size alone; these are the small ones whose absence
|
|
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
|
|
for expected in \
|
|
"/opt/$dir/DodoSSH" \
|
|
"/usr/bin/$bin" \
|
|
"/usr/share/applications/$bin.desktop" \
|
|
"/usr/share/icons/hicolor/256x256/apps/$bin.png"
|
|
do
|
|
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
|
|
echo "The package was built and $expected is not in it." >&2
|
|
printf '%s\n' "$files" | head -40 >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
|
|
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
|
|
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
|
|
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
|
|
# other account on the machine runs.
|
|
#
|
|
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
|
|
# link points at is what has permissions.
|
|
writable="$(
|
|
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
|
|
grep -v '^l' |
|
|
awk '$1 ~ /^.{8}w/ { print }'
|
|
)"
|
|
if [ -n "$writable" ]; then
|
|
echo "This package contains world-writable files:" >&2
|
|
printf '%s\n' "$writable" >&2
|
|
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "==> requires:"
|
|
printf '%s\n' "$requires" | sed 's/^/ /'
|
|
|
|
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
|
|
|
|
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
|
|
#
|
|
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
|
|
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
|
|
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
|
|
#
|
|
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
|
|
#
|
|
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
|
|
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
|
|
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
|
|
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
|
|
#
|
|
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
|
|
# should be able to answer with the string the preferences screen prints, and that screen reads the
|
|
# assembly.
|
|
#
|
|
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
|
|
#
|
|
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
|
|
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
|
|
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
|
|
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
|
|
#
|
|
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
|
|
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
|
|
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
|
|
manifest="$out/linux-$channel.json"
|
|
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
|
|
|
|
cat > "$manifest" <<JSON
|
|
{
|
|
"rpmVersion": "$rpm_version-$rpm_release",
|
|
"version": "$version",
|
|
"rpm": "$(basename "$rpm_file")",
|
|
"sha256": "$digest"
|
|
}
|
|
JSON
|
|
|
|
echo "==> $(basename "$manifest"):"
|
|
sed 's/^/ /' "$manifest"
|
|
|
|
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
|
|
# desk use above still tells you where the file went.
|
|
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
|
{
|
|
echo "rpm=$rpm_file"
|
|
echo "rpmName=$(basename "$rpm_file")"
|
|
echo "manifest=$manifest"
|
|
echo "manifestName=$(basename "$manifest")"
|
|
} >> "$GITHUB_OUTPUT"
|
|
fi
|
|
echo "$rpm_file"
|