Skip to content

Commit cc0185f

Browse files
Windows: guard the source-build and whisper.cpp probes on an unreadable install tree (#7757)
* Windows: guard the source-build and whisper.cpp probes on an unreadable install tree Follow-up to #7735, which routed the prebuilt llama.cpp probes through three-state path probing but left two gaps. Phase 4 read $LlamaServerBin with a bare Test-Path under "Stop". A forced compile, a pinned PR or a custom llama source skips Phase 3.4 entirely, so on those routes this was the first probe inside the tree and a denied build\ aborted with the raw "Test-Path : Access is denied" the merged PR set out to remove. It now probes three-state, and the CMakeCache.txt read below it is guarded too: a listed file can still deny the read, which the probe cannot see. The probe is skipped for a linked UNSLOTH_LOCAL_LLAMA_CPP_DIR, where it would read through the junction into the user's own checkout, and the denial reports -OwnershipUnverified under a custom home, where nothing on this route has proven the tree is ours. The whisper.cpp phase promises failure is never fatal, but under a custom UNSLOTH_STUDIO_HOME an unreadable tree exited the whole run, taking llama.cpp inference down with it. Assert-StudioOwnedOrAbsent gains a -NonFatal mode that hands the denial back instead; an unowned tree still stops. The check stays behind the installer-exists gate it used to sit inside, so a tree without install_whisper_prebuilt.py remains the no-op it was. Backend: _is_runnable let Path.is_file() propagate EACCES. Now that setup leaves a denied whisper.cpp in place, that turned into a 500 out of /api/inference/audio/stt/status, the one endpoint reporting both dictation engines, so the setup message promising Transformers dictation still works was not true. It reads as engine-unavailable instead. * Harden the denial contract tests against surviving mutations Mutation testing found six ways to reintroduce the bugs this branch fixes while the tests stayed green. Assert-StudioOwnedOrAbsent: the -NonFatal returns were counted, not ordered. Moving one below its Exit-PathAccessDenied makes it dead code and the whisper phase fatal again; hoisting one above the custom-home gate reports a fresh install as unreadable. Each return is now pinned immediately above the exit it pre-empts, with no unpaired return allowed. The whisper denial branch had no assertion scoped to its own body. Both phrases it was checked for already occur elsewhere in the phase, so the branch could be turned back into an Exit-SetupFailure and stay green. The branch is now sliced out and checked for step/Yellow, both phrases, and the absence of any exit. The installer gate was checked for presence, not for being a conjunct, so -or-joining or negating it reopened the installer-less tree the test is named for. The denial subject was unpinned, so it could name llama-server.exe and tell the user to move aside one file instead of the tree. Slice terminators are now asserted through one helper: an unasserted terminator does not fail, it silently widens the window to end-of-file and makes everything inside it near-vacuous. The whisper binary probe test gated its only behavioural case on geteuid() == 0, which silently drops it in any root container. It probes for a real denial instead. Two pre-existing exact counts in the ownership guard tests become floors: this branch consumed the last of their headroom, so the next legitimate route added there would break two tests that say nothing about it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix the -NonFatal negative control for Windows ACL semantics The control probed a marker file that did not exist. Windows reports a missing child of a denied directory as absent rather than throwing, so the control read as "this host cannot deny" and failed the suite on windows-latest while passing under chmod on Linux. It now probes a file that exists inside the locked tree, matching the control the suite already uses. That difference also splits the routes by platform for a tree with no ownership marker, which is the fresh custom-home case: Linux catches it on the marker probe, Windows has to catch it on the adoptable-state read. Added a case that accepts either route and rejects anything but Denied, so the Windows one is exercised for the first time. * Detect a denied tree that has no ownership marker on Windows Staging CI on windows-latest caught this. Get-StudioAdoptableState decided "denied" only from probes of two marker files inside the tree, but Windows reports a MISSING child of an unreadable directory as absent rather than throwing. A denied tree holding neither marker therefore returned "No", and Assert-StudioOwnedOrAbsent fell through to "path is not an Unsloth-owned install" and exited: the wrong cause, and fatal, on the only platform any of this runs on. It also defeated the whisper -NonFatal path, since an unowned tree is still fatal by design. Listing the directory itself distinguishes "no markers here" from "cannot look", so that is the fallback when neither probe reported a denial. A readable tree with no markers still returns "No" as before, and the catch swallows anything that is not a denial because this helper must not throw. This also corrects the message a denied custom-home llama.cpp tree produced on Windows, which reported the same wrong cause. chmod 000 blocks the child probes outright, so it never reached the new code. chmod 111 allows stat of a named child while forbidding a listing, which is exactly the Windows shape, so the test now covers both and the negative control fires only on the 111 case. * Tighten comments in the denied-tree and whisper install changes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 259ec3b commit cc0185f

6 files changed

Lines changed: 358 additions & 16 deletions

File tree

studio/backend/core/inference/stt_ggml_sidecar.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,12 @@ def _is_runnable(p: Path) -> bool:
172172
"""A real whisper-server is an executable file. On Windows os.access(X_OK) is
173173
effectively an existence check; on Unix it rejects a non-executable stub so a
174174
half-written or wrong-mode file isn't mistaken for the server."""
175-
return p.is_file() and (sys.platform == "win32" or os.access(p, os.X_OK))
175+
try:
176+
return p.is_file() and (sys.platform == "win32" or os.access(p, os.X_OK))
177+
except OSError:
178+
# is_file() propagates EACCES: an unreadable install dir must read as
179+
# engine-unavailable, like a missing one, never a 500 out of stt/status.
180+
return False
176181

177182

178183
def _whisper_install_marker(binary: str) -> Optional[dict]:

studio/setup.ps1

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2940,6 +2940,14 @@ function Get-StudioAdoptableState {
29402940
}
29412941
}
29422942
if ($denied) { return "Denied" }
2943+
# Windows reports a MISSING child of an unreadable directory as absent, so the
2944+
# probes above cannot tell "no markers here" from "cannot look"; listing the
2945+
# directory itself can. Without this a denied tree reads as unowned.
2946+
try { $null = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop | Select-Object -First 1) }
2947+
catch {
2948+
if (Test-AccessDeniedError $_) { return "Denied" }
2949+
# Anything else was not adoptable before either; this must not throw.
2950+
}
29432951
return "No"
29442952
}
29452953
# Boolean view for callers that only gate a cosmetic cleanup on adoption.
@@ -2950,7 +2958,10 @@ function Test-StudioOwnedAdoptable {
29502958
function Assert-StudioOwnedOrAbsent {
29512959
param(
29522960
[Parameter(Mandatory = $true)][string]$Path,
2953-
[Parameter(Mandatory = $true)][string]$Label
2961+
[Parameter(Mandatory = $true)][string]$Label,
2962+
# whisper.cpp is non-fatal by contract, so it needs the denial handed back
2963+
# rather than exited on. Only this mode returns a value.
2964+
[switch]$NonFatal
29542965
)
29552966
# Denied is not Absent: a root we cannot read cannot be proven ours, and
29562967
# returning here would let the caller replace it. Both stops stay gated on
@@ -2959,17 +2970,20 @@ function Assert-StudioOwnedOrAbsent {
29592970
$pathState = Get-PathState -Path $Path -PathType Container
29602971
if ($pathState -ne "Present") {
29612972
if ($StudioHomeIsCustom -and $pathState -eq "Denied") {
2973+
if ($NonFatal) { return "Denied" }
29622974
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
29632975
}
29642976
return
29652977
}
29662978
$markerState = Get-PathState -Path (Join-Path $Path $StudioOwnedMarker) -PathType Leaf
29672979
if ($StudioHomeIsCustom -and $markerState -eq "Denied") {
2980+
if ($NonFatal) { return "Denied" }
29682981
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
29692982
}
29702983
if ($StudioHomeIsCustom -and $markerState -ne "Present") {
29712984
$adoptState = Get-StudioAdoptableState -Path $Path
29722985
if ($adoptState -eq "Denied") {
2986+
if ($NonFatal) { return "Denied" }
29732987
Exit-PathAccessDenied -Path $Path -Label $Label -OwnershipUnverified
29742988
}
29752989
if ($adoptState -eq "Yes") {
@@ -4333,6 +4347,12 @@ if ($env:WHISPER_SERVER_PATH -or $env:UNSLOTH_WHISPER_CPP_PATH) {
43334347
substep "whisper.cpp: using a user-configured binary/dir; skipping managed install"
43344348
} elseif ($env:UNSLOTH_SKIP_WHISPER_INSTALL -eq "1") {
43354349
substep "whisper.cpp: install skipped (UNSLOTH_SKIP_WHISPER_INSTALL=1)"
4350+
} elseif ($StudioHomeIsCustom -and (Test-Path -LiteralPath $WhisperInstaller) -and
4351+
(Assert-StudioOwnedOrAbsent -Path $WhisperCppDir -Label "whisper.cpp install" -NonFatal) -eq "Denied") {
4352+
# Never fatal, per the phase header: the guard below would exit the whole run
4353+
# on an unreadable tree, taking llama.cpp down with it. Only the denial is
4354+
# caught here; an unowned tree still stops.
4355+
step "whisper.cpp" "install directory cannot be read: access is denied; curated whisper.cpp dictation is unavailable; restore access to $WhisperCppDir or move it aside, then re-run setup; browser and Transformers dictation remain available" "Yellow"
43364356
} elseif (Test-Path -LiteralPath $WhisperInstaller) {
43374357
# The installer's atomic activation replaces the whole directory, so the
43384358
# custom-home ownership guard must run first (mirrors the llama block).
@@ -4372,7 +4392,7 @@ if ($env:WHISPER_SERVER_PATH -or $env:UNSLOTH_WHISPER_CPP_PATH) {
43724392
} else {
43734393
step "whisper.cpp" "prebuilt installed"
43744394
}
4375-
if ($StudioHomeIsCustom -and (Test-Path -LiteralPath $WhisperCppDir -PathType Container)) {
4395+
if ($StudioHomeIsCustom -and (Test-PathQuiet $WhisperCppDir "Container")) {
43764396
Mark-StudioOwned -Path $WhisperCppDir
43774397
}
43784398
} elseif ($whisperExit -eq 3) {
@@ -4466,10 +4486,25 @@ $HasGitForBuild = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
44664486
# Check if existing llama-server matches current GPU mode. A CUDA-built binary
44674487
# on a now-CPU-only machine (or vice versa) needs to be rebuilt.
44684488
$NeedRebuild = $false
4469-
if (Test-Path -LiteralPath $LlamaServerBin) {
4489+
# A forced compile, a pinned PR or a custom source skips the prebuilt path and its
4490+
# phase 3.4 denial guard, so this can be the first probe to read inside the tree.
4491+
# A linked local dir is skipped: it reads through the junction into the user's own
4492+
# checkout, and nothing here is consumed on that path anyway.
4493+
$llamaBinState = if ($LocalLlamaCppLinked) { "Absent" } else { Get-PathState -Path $LlamaServerBin -PathType Leaf }
4494+
if ($llamaBinState -eq "Denied") {
4495+
# Nothing proved this tree is ours here, so do not advise deleting it.
4496+
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
4497+
}
4498+
if ($llamaBinState -eq "Present") {
44704499
$CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt"
4471-
if (Test-Path -LiteralPath $CmakeCacheFile) {
4472-
$cachedCuda = Select-String -LiteralPath $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
4500+
if (Test-PathQuiet $CmakeCacheFile "Leaf") {
4501+
# A listed file can still deny the read, which Test-PathQuiet cannot see.
4502+
try {
4503+
$cachedCuda = Select-String -LiteralPath $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
4504+
} catch {
4505+
if (-not (Test-AccessDeniedError $_)) { throw }
4506+
Exit-PathAccessDenied -Path $LlamaCppDir -Label "llama.cpp install" -OwnershipUnverified:$StudioHomeIsCustom
4507+
}
44734508
if ($HasNvidiaSmi -and -not $cachedCuda) {
44744509
Write-Host " Existing llama-server is CPU-only but GPU is available -- rebuilding" -ForegroundColor Yellow
44754510
$NeedRebuild = $true
@@ -4485,7 +4520,7 @@ if (Test-Path -LiteralPath $LlamaServerBin) {
44854520
# build runs only when needed and no usable binary is already present. A linked
44864521
# local dir sets $NeedLlamaSourceBuild = $false, so this no-ops for that path.
44874522
$WillBuildLlamaFromSource = $NeedLlamaSourceBuild -and `
4488-
-not ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master")
4523+
-not ((Test-PathQuiet $LlamaServerBin "Leaf") -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master")
44894524
if ($WillBuildLlamaFromSource) {
44904525
if (-not $HasGitForBuild) {
44914526
# Phase 1 keeps git optional, so only the automatic fallback after a failed prebuilt
@@ -4519,7 +4554,7 @@ if ($LocalLlamaCppLinked) {
45194554
} elseif (-not $NeedLlamaSourceBuild) {
45204555
Write-Host ""
45214556
step "llama.cpp" "prebuilt (validated)"
4522-
} elseif ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
4557+
} elseif ((Test-PathQuiet $LlamaServerBin "Leaf") -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
45234558
# Skip rebuild only for pinned tags (e.g. b8635). When the requested
45244559
# tag is "master" (a moving target), always rebuild so the binary picks
45254560
# up new model architecture support (e.g. Gemma 4).
@@ -5063,16 +5098,16 @@ if ($LocalLlamaCppLinked) {
50635098
$totalSec = [math]::Round($totalSw.Elapsed.TotalSeconds % 60, 1)
50645099

50655100
# -- Summary --
5066-
if ($BuildOk -and (Test-Path -LiteralPath $LlamaServerBin)) {
5101+
if ($BuildOk -and (Test-PathQuiet $LlamaServerBin "Leaf")) {
50675102
step "llama.cpp" "built"
50685103
$QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe"
5069-
if (Test-Path -LiteralPath $QuantizeBin) {
5104+
if (Test-PathQuiet $QuantizeBin "Leaf") {
50705105
step "llama-quantize" "built"
50715106
}
50725107
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
50735108
} else {
50745109
$altBin = Join-Path $BuildDir "bin\llama-server.exe"
5075-
if ($BuildOk -and (Test-Path -LiteralPath $altBin)) {
5110+
if ($BuildOk -and (Test-PathQuiet $altBin "Leaf")) {
50765111
step "llama.cpp" "built"
50775112
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
50785113
} else {

tests/studio/install/test_setup_denied_install_tree.py

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,23 @@ def test_ownership_guard_distinguishes_denied_from_unowned():
102102
# unreadable; it must stay for the genuinely-unowned case only.
103103
assert "is not marked as an Unsloth-owned $Label" in guard
104104
# Both stops stay gated, so default-home installs behave exactly as before.
105-
assert guard.count("$StudioHomeIsCustom -and") == 3
105+
assert guard.count("$StudioHomeIsCustom -and") >= 3
106106

107107

108108
def test_no_bare_test_path_probes_inside_the_llama_install_tree():
109109
"""Probes that read *inside* a tree whose permissions we do not control are
110110
the ones that throw; they must all go through the guarded helpers."""
111111
inside_tree = re.compile(
112-
r"Test-Path\b[^\n]*(\$existingMetaPath|\$llamaMarker|\$_cand|Join-Path \$LlamaCppDir)"
112+
r"Test-Path\b[^\n]*("
113+
r"\$existingMetaPath|\$llamaMarker|\$_cand|Join-Path \$LlamaCppDir"
114+
r"|\$LlamaServerBin|\$CmakeCacheFile|\$QuantizeBin|\$altBin"
115+
r"|Join-Path \$BuildDir)"
113116
)
117+
# A comment naming a probe is not a probe.
114118
offenders = [
115119
f"{index}: {line.strip()}"
116120
for index, line in enumerate(SETUP_PS1.splitlines(), start = 1)
117-
if inside_tree.search(line)
121+
if inside_tree.search(line.split("#", 1)[0])
118122
]
119123
assert not offenders, offenders
120124

@@ -232,7 +236,7 @@ def test_the_ownership_guard_never_advises_deleting_an_unverified_tree():
232236
the tree is ours. It already says "move it aside" when it can prove it."""
233237
guard = SETUP_PS1.split("function Assert-StudioOwnedOrAbsent", 1)[1].split("\nfunction ", 1)[0]
234238
calls = [line.strip() for line in guard.splitlines() if "Exit-PathAccessDenied" in line]
235-
assert len(calls) == 3, calls
239+
assert len(calls) >= 3, calls
236240
for line in calls:
237241
assert "-OwnershipUnverified" in line, line
238242
body = SETUP_PS1.split("function Exit-PathAccessDenied", 1)[1].split("\nfunction ", 1)[0]
@@ -265,3 +269,108 @@ def test_the_temp_dir_swap_checks_both_of_its_destructive_steps():
265269
# And catch a move that silently did not happen.
266270
assert '(Get-PathState -Path $LlamaCppDir) -ne "Absent"' in swap.split(move, 1)[1], swap
267271
assert "Test-Path -LiteralPath $OriginalLlamaCppDir" not in swap, swap
272+
273+
274+
def test_the_source_build_phase_probes_the_tree_three_state():
275+
"""A forced compile, a pinned PR or a custom source skips the prebuilt phase
276+
and its denial guard, so the rebuild check is the first read inside the tree."""
277+
build = _slice("$llamaBinState = ", "# -- Summary --")
278+
assert "Get-PathState -Path $LlamaServerBin -PathType Leaf" in build, build
279+
assert '$llamaBinState -eq "Denied"' in build, build
280+
assert '$llamaBinState -eq "Present"' in build, build
281+
assert "Test-Path -LiteralPath $LlamaServerBin" not in build, build
282+
283+
284+
def test_the_source_build_probe_skips_a_linked_local_dir():
285+
"""$LlamaCppDir is a junction onto the user's checkout there, so probing it
286+
reads their tree, and nothing this block computes is consumed on that path."""
287+
flat = " ".join(SETUP_PS1.split())
288+
assert '$llamaBinState = if ($LocalLlamaCppLinked) { "Absent" }' in flat
289+
290+
291+
def test_the_source_build_denial_never_advises_deleting_an_unproven_tree():
292+
"""Nothing on this route ran the ownership guard, so under a custom home the
293+
tree cannot be proven ours and the delete advice must stay suppressed."""
294+
block = _slice("$llamaBinState = ", "$WillBuildLlamaFromSource")
295+
denials = [ln.strip() for ln in block.splitlines() if "Exit-PathAccessDenied" in ln]
296+
assert len(denials) >= 2, denials
297+
assert all(d.endswith("-OwnershipUnverified:$StudioHomeIsCustom") for d in denials), denials
298+
assert all(d.startswith("Exit-PathAccessDenied -Path $LlamaCppDir ") for d in denials), denials
299+
300+
301+
def test_the_cmake_cache_read_is_guarded_not_just_its_probe():
302+
"""Test-PathQuiet only proves the entry is listed; a deny ACE on the file
303+
itself leaves the probe true and throws on the read below it."""
304+
block = _slice("$CmakeCacheFile = Join-Path", "$WillBuildLlamaFromSource")
305+
read = "Select-String -LiteralPath $CmakeCacheFile"
306+
assert read in block, block
307+
before, after = block.split(read, 1)
308+
assert "try {" in before, before
309+
assert "Test-AccessDeniedError" in after, after
310+
assert "Exit-PathAccessDenied" in after, after
311+
312+
313+
def _slice(start: str, end: str) -> str:
314+
"""Both bounds asserted: an unasserted terminator does not fail, it silently
315+
widens the window to end-of-file and makes everything inside it near-vacuous."""
316+
assert start in SETUP_PS1, start
317+
assert end in SETUP_PS1, end
318+
return SETUP_PS1.split(start, 1)[1].split(end, 1)[0]
319+
320+
321+
def _whisper_phase() -> str:
322+
"""The whisper phase body. Both anchors are asserted so a phase renumbering
323+
fails as an assertion instead of an IndexError, and cannot silently widen
324+
the window to end-of-file."""
325+
return _slice("Install the whisper.cpp prebuilt", "PHASE 3.5")
326+
327+
328+
def test_the_whisper_phase_survives_an_unreadable_whisper_tree():
329+
"""The phase header promises failure is never fatal, but the ownership guard
330+
exits the whole run, which would take llama.cpp inference down with it."""
331+
guard = SETUP_PS1.split("function Assert-StudioOwnedOrAbsent", 1)[1].split("\nfunction ", 1)[0]
332+
assert "[switch]$NonFatal" in guard
333+
# Ordering, not just presence: below its exit the return is dead code.
334+
paired = re.findall(
335+
r'if \(\$NonFatal\) \{ return "Denied" \}\n\s*Exit-PathAccessDenied -Path \$Path', guard
336+
)
337+
assert len(paired) == guard.count("Exit-PathAccessDenied -Path $Path"), guard
338+
# No unpaired return: one above the custom-home gate would call a fresh
339+
# install unreadable.
340+
assert len(paired) == guard.count('if ($NonFatal) { return "Denied" }'), guard
341+
assert len(paired) >= 3, guard
342+
# Only the denial is handed back; an unowned tree must still stop.
343+
assert 'Exit-SetupFailure "$Label path is not an Unsloth-owned install' in guard
344+
whisper = _whisper_phase()
345+
assert '-Label "whisper.cpp install" -NonFatal) -eq "Denied"' in whisper, whisper
346+
# Scoped to the new branch: both phrases occur elsewhere in the phase, so a
347+
# phase-wide match proves nothing about this branch.
348+
marker = '-NonFatal) -eq "Denied") {'
349+
assert marker in whisper, whisper
350+
denial = whisper.split(marker, 1)[1].split("\n} elseif", 1)[0]
351+
assert re.search(r'^\s*step "whisper\.cpp" ', denial, re.M), denial
352+
assert "install directory cannot be read: access is denied" in denial, denial
353+
assert "browser and Transformers dictation remain available" in denial, denial
354+
# The whole point is that this stays non-fatal.
355+
assert "Exit-SetupFailure" not in denial, denial
356+
assert not re.search(r"\bexit \d", denial), denial
357+
# The skip must precede the branch whose guard would exit. Anchored on that
358+
# branch's body, which survives a hardening of its own probe.
359+
body = "$whisperArgs = @("
360+
assert body in whisper, whisper
361+
assert whisper.index("-NonFatal") < whisper.index(body)
362+
363+
364+
def test_the_whisper_skip_stays_behind_the_installer_gate():
365+
"""The guard used to live inside the installer branch, so a tree without the
366+
installer was a no-op. Hoisting it must not make that case fatal."""
367+
marker = "-NonFatal) -eq"
368+
whisper = _whisper_phase()
369+
assert marker in whisper, whisper
370+
head = whisper.split(marker, 1)[0]
371+
assert "} elseif" in head, head
372+
branch = head.rsplit("} elseif", 1)[1]
373+
# A conjunct, not merely present: -or or a negation reopens this case.
374+
assert re.search(r"-and\s*\([^\n]*\$WhisperInstaller[^\n]*\)\s*-and", branch), branch
375+
assert "-not (Test-Path" not in branch, branch
376+
assert " -or " not in branch, branch

0 commit comments

Comments
 (0)