Commit Graph
46973 Commits
Author SHA1 Message Date
Henrik Rydgård cd052ea640 Adjust the level of Claude-based paranoia here and there 2026-08-11 20:08:01 +02:00
Henrik Rydgård a3e0f915af Claude being too cautious. Let's just reject bad values fully. 2026-08-11 20:08:01 +02:00
Henrik Rydgård 3806d00e58 Merge pull request #22080 from hrydgard/fix-debugger-websocket-review
Core/Debugger/Websocket review and fixes by Claude
2026-08-11 18:13:29 +02:00
Henrik Rydgård 4bd303d1d7 Merge pull request #22083 from tclamb/master
Fix assembly/disassembly for various VFPU opcodes
2026-08-11 16:44:50 +02:00
Thomas Lamb bf67ed300d Fix assembly/disassembly for various VFPU opcodes
Update armips for VFPU instruction parsing changes (Kingcom/armips#257)
- fix transposed matrix register encodings (e.g. `vmidt.p E220` was assembling to `vmidt.p E202`)
- fix immediate size for `vwbn.s` (is 8 bits, was limited to 5 bits)
- fix `vd` register size for `vsocp.s/p`
- fix `vhtfm2.p` and `vhtfm3.t` encoding
- add missing instruction `vhtfm4.q`

Fix `v(h)tfmX` disassembly (handle irregular vector size encoding)

Fix `vsocp.s/p` disassembly (`vd` is double the size of `vs`)
2026-08-11 10:10:56 -04:00
Henrik Rydgård a3bf3c7153 Cut down on Claude's excesses 2026-08-11 15:43:48 +02:00
Henrik Rydgård 6929a0d9ae GPUBufferSubscriber: bound stackWidth and validate texture level
gpu.buffer.*'s "uri" output type let a client supply an arbitrary
stackWidth with no upper bound, used as the starting divisor in a loop
that decrements until it evenly divides the buffer's actual (small)
pixel count - a client sending a huge stackWidth (up to ~2 billion)
stalls the connection's handler thread for that many iterations.
Clamp it to the actual pixel count first.

gpu.buffer.texture's level parameter was forwarded as-is (u32) into
GPU_GetCurrentTexture(), which takes a plain int - a client-supplied
value whose u32->int conversion is negative skips backends' "level >=
mip count" bounds check (which only fires for level > 0), reaching
backend texture-copy code with a bogus mip index. Reject it upfront.
2026-08-11 15:40:30 +02:00
Henrik Rydgård 17d899d2d3 Replay: avoid size_t wraparound in side-data truncation check
ReplayExecuteBlob's bounds check (i + item.info.size > sz) can
overflow on platforms where size_t is 32-bit, since item.info.size is
a client/file-supplied u32 read from replay.execute's base64 blob -
a crafted value near UINT32_MAX could wrap the sum below sz, passing
the check before an out-of-bounds memcpy. Use a subtraction-based
check instead, which can't overflow regardless of size_t width.
2026-08-11 15:40:30 +02:00
Henrik Rydgård 06522e91a0 BreakpointSubscriber: apply the same overflow check to breakpoint removal
WebSocketMemoryBreakpointParams::Parse() (used by add/update) checks
for address + size wrapping around before computing the end address,
but memory.breakpoint.remove computed it inline without that check.
Apply the same check for consistency - a crafted size could otherwise
wrap the computed end below address, causing RemoveMemCheck to operate
on an unintended range.
2026-08-11 15:40:30 +02:00
Henrik Rydgård ffa6704317 Merge pull request #22078 from hrydgard/more-read_u32-cleanup
More Read_U32 (and similar) cleanup
2026-08-11 11:09:28 +02:00
Henrik Rydgård 138f02217c More memory access cleanup 2026-08-11 10:28:48 +02:00
Henrik Rydgård d8edeb7649 Interpreter: Add correct alignment checks to loads/stores, cleanup 2026-08-11 10:28:48 +02:00
Henrik Rydgård 5dfeaed63b More explicit error checking 2026-08-11 10:28:47 +02:00
Henrik Rydgård 3c81d6b121 More manual Read_U32 cleanup
Buildfix
2026-08-11 10:28:36 +02:00
Henrik Rydgård 5417ef0333 Merge pull request #22075 from hrydgard/fix-util-review
Core/Util fixes by Claude
2026-08-11 10:13:46 +02:00
Henrik Rydgård c416916cb5 Merge pull request #22076 from hrydgard/fix-filesystems-review
Core/Filesystems fixes by Claude
2026-08-11 10:13:37 +02:00
Henrik Rydgård e1973a3505 Merge pull request #22077 from hrydgard/fix-fileloaders-review
Core/Fileloaders fixes by Claude
2026-08-11 10:13:25 +02:00
Henrik Rydgård af9891cd7d More Read_U32 and Read_U64 2026-08-11 09:08:52 +02:00
Henrik Rydgård 4f8859d0ec Centralize a disassembly utility function between the debuggers 2026-08-11 09:08:52 +02:00
Henrik Rydgård 9396030f02 More Read_U32 cleanup 2026-08-11 09:08:52 +02:00
Henrik Rydgård f15f8453f0 ISOFileSystem: fix path table re-read bug and validate directory entry size
Ioctl's ISO9660 path table read re-read sector `block` (the first
sector, already consumed by the preceding ReadBlocks) for the trailing
partial sector instead of `block + blocks`, returning duplicated data
from the start of the table instead of its actual tail.

ReadDirectory() advanced by the raw on-disk dir.size without checking
it's at least as large as the record's own header+identifier. A
crafted directory sector could set dir.size = 1 repeatedly, making the
loop reinterpret the same overlapping bytes as many separate entries -
allocating far more TreeEntry objects than the sector's actual size
should allow.
2026-08-11 08:57:15 +02:00
Henrik Rydgård c329363a7d tlzrc: fix undersized probability-table arrays in the LZRC decoder
rc_bittree()/rc_number() can index bm_dist_bits up to column 50 and
bm_dist up to row 43 (when decoding a "long distance" match, i.e.
match_len > 2), but the arrays were only sized for 39 and 18
respectively. A crafted LZRC-compressed block (reachable via
NPDRMDemoBlockDevice::ReadBlock) could drive these indices out of
range, corrupting adjacent probability tables within the same
LZRC_DECODE struct via rc_bit()'s read-modify-write. Size the arrays
for the indices the algorithm can actually produce, and initialize
them via sizeof() so the memset in rc_init stays correct.
2026-08-11 08:57:15 +02:00
Henrik Rydgård 93a8b0e501 VirtualDiscFileSystem: reject ".." in .ppsspp-index.lst entries
fileName is taken verbatim from the index file (only a leading slash
is stripped) and then used essentially unsanitized to build a path
under basePath (GetLocalPath() is a plain string join, unlike
MetaFileSystem::RealPath which does collapse ".." for normal game file
access). A crafted index file - these virtual-disc folders are commonly
shared/downloaded as homebrew - could use a ".." component to make
PPSSPP probe or open arbitrary host files/directories outside the
intended folder just by loading it. Reuse the existing
HasParentDirComponent() helper (already used for the same purpose in
GameManager's zip extraction) to reject such entries.
2026-08-11 08:57:15 +02:00
Henrik Rydgård a82043eb9d CISOFileBlockDevice: reject frame/block count mismatches
numFrames and numBlocks are derived from the same attacker-controlled
64-bit total_bytes field, but independently truncated to 32 bits using
different divisors (frameSize vs. the fixed 2048-byte block size).
With extreme total_bytes/block_size combinations the two truncations
can disagree so that numBlocks (which gates ReadBlock's bounds check)
describes more blocks than numFrames actually covers - ReadBlock then
indexes the numFrames+1-sized `index` array one or more elements past
its end. Reject any header where this could happen before allocating
anything.
2026-08-11 08:57:15 +02:00
Henrik Rydgård e94463b4b6 NPDRMDemoBlockDevice: validate untrusted PBP header/table fields
The block table read from an NPDRM PBP's PSAR blob is only reversibly
XOR-scrambled, not otherwise validated, so a crafted file fully
controls table_[block].size/offset and the header's LBA/block-size
fields. Several of these were used without checks:

- table_[block].size could exceed blockSize_, causing ReadAt and the
  KIRK cipher update to write past the end of blockBuf_/tempBuf_ (both
  allocated as exactly blockSize_ bytes) - a heap buffer overflow.
- The block index derived from blockNumber (which can come from an
  attacker-influenced /sce_lbn.../_size... raw sector open) was never
  bounds-checked against numBlocks_ before indexing table_[].
- blockLBAs_ could be 0, dividing by zero both when computing
  numBlocks_ and when computing the block index in ReadBlock.
- lbaSize_ could underflow if lbaEnd < lbaStart, and tableSize_
  (numBlocks_ * sizeof(table_info)) was computed in 32-bit, so a large
  numBlocks_ could wrap it to a small value - passing the "did we read
  the whole table" check while only actually reading (and
  descrambling) a small prefix, leaving the rest of the table_ array
  as untouched, uninitialized heap memory that ReadBlock() would later
  trust.

Reject all of these instead.
2026-08-11 08:57:15 +02:00
Henrik Rydgård 67f2bf57cb RamCachingFileLoader: don't round a mid-file short read up to a full block
blocksActuallyRead rounded bytesRead up to the next whole block
unconditionally, intending to handle the legitimate case where the
very last block of the file is naturally shorter than BLOCK_SIZE
(cache_ is deliberately over-allocated for that). But it applied the
same rounding to any short read, including a genuine failure or a
dropped connection mid-file (this loader can sit on top of the whole
Remote ISO chain via CachingFileLoader/HTTPFileLoader when "Cache full
ISO in RAM" is enabled) - marking a block as fully cached when only a
few of its bytes were actually written. Since cache_ is malloc'd (not
zeroed), every later read of that block would serve uninitialized heap
memory as if it were real file data.

Only round up when the short read's end position exactly matches the
true end of the file.
2026-08-11 08:54:16 +02:00
Henrik Rydgård f7f92c5db7 DiskCachingFileLoader: fix partial-read caching, index bounds, and offset bug
SaveIntoCache checked `readBytes != 0` instead of comparing against the
full expected length, so any nonzero-but-short read from the backend
(e.g. a Remote ISO connection dropping mid-file) was treated as a
complete success: in the multi-block path this marked *all* requested
blocks (up to 16) as fully cached and wrote the uninitialized tail of
the read buffer to the on-disk cache file, and in both paths the
short/uninitialized data was also copied straight into the caller's
output buffer and counted in the return value - so a read failure was
reported (and permanently cached) as success. Only treat a block as
read once the backend actually delivered the full blockSize_ for it,
and stop before caching or returning anything for blocks it didn't.

Also fixes two latent bugs in the same functions, unreachable in the
current call graph (DiskCachingFileLoader is only ever driven by
CachingFileLoader, which always issues block-aligned reads) but wrong
if ever called otherwise:
- The multi-block loop reused the batch's initial `offset` (the
  position within the *first* block) for every subsequent block
  instead of resetting it to 0, which would both read from the wrong
  place in `wholeRead` and mis-copy less than a full block for i > 0.
- ReadBlockData() applied `offset` to the destination pointer instead
  of the file seek position, which would both read the wrong bytes
  from disk and write up to `offset` bytes past the end of the
  caller's buffer.

LoadCacheIndex's sanity check on persisted block indices used `>`
instead of `>=` against maxBlocks_ (blockIndexLookup_ only has
maxBlocks_ entries, valid indices 0..maxBlocks_-1), so a corrupted
cache file's index entry with block == maxBlocks_ exactly would pass
validation and then index one past the end of blockIndexLookup_.
2026-08-11 08:54:16 +02:00
Henrik Rydgård 7e64052914 CachingFileLoader: don't cache blocks the backend failed to fully read
SaveIntoCache() discarded backend_->ReadAt()'s return value entirely
and unconditionally marked the requested block(s) as cached. A short
or failed read from the backend (e.g. a Remote ISO connection dropping
mid-file, now that LocalFileLoader/RetryingFileLoader correctly report
failures as 0 rather than a huge count) would still get stored as a
"valid" cached block, permanently serving its uninitialized tail as if
it were real file data on every later read, with no retry.

Only insert a block once we've confirmed the backend actually
delivered the full BLOCK_SIZE for it.
2026-08-11 08:54:16 +02:00
Henrik Rydgård 1671814be1 LocalFileLoader: report 0, not a huge bogus count, on read failure
ReadAt()'s contract is to return the number of bytes/units actually
read. On every platform branch, an OS-level read failure (ReadFile
returning FALSE, or pread/read returning -1) was fed straight into a
division by `bytes` without checking for it first:
- Windows explicitly returned (size_t)-1.
- Elsewhere, the signed -1 from pread/read was implicitly converted to
  size_t (via the usual arithmetic conversions with the unsigned
  `bytes`) before the division, producing a huge bogus count instead
  of a small one.

Every caller in the caching chain (CachingFileLoader,
RamCachingFileLoader, RetryingFileLoader, ZipFileLoader's libzip
source callback) loops on "did we get at least what we asked for",
which a huge return value trivially satisfies - so a local I/O error
(removable media ejected, a content-URI permission problem mid-read,
etc.) would be reported as a fully successful read of whatever
uninitialized memory happened to be in the destination buffer.
2026-08-11 08:54:16 +02:00
Henrik Rydgård c5849ada33 GameDB: validate CSV structure before indexing into it
LoadIfNeeded() indexed lines[0] unconditionally after splitting the
bundled CSV into lines, crashing if the file were empty. It also used
GetColumnIndex()'s result directly as an index into each row's fields
without checking for its (size_t)-1 "not found" sentinel, which would
have produced a huge out-of-bounds index on every row if any of the
expected column headers were missing. Both require a corrupted or
replaced redump.csv asset to trigger, but are simple, cheap checks to
add.
2026-08-11 08:43:52 +02:00
Henrik Rydgård eb68bdba0b BlockAllocator: fix null deref in GetBlockTag, bound DoState's block count
GetBlockTag() dereferenced GetBlockFromAddress()'s result without a
null check, unlike every other accessor in this file. Callers
(NetAdhocCommon.cpp, sceNet.cpp, sceNetAdhocMatching.cpp) pass the
result straight into strcmp() while recovering from a stale address
left over from an old/corrupted savestate - precisely the situation
where the address may no longer resolve to a block. Return "" instead
of dereferencing a null block, so strcmp() simply reports a mismatch
(correctly triggering those callers' recovery path) instead of
crashing.

DoState() read the saved block count directly from the savestate with
no validation before looping that many times allocating Blocks - a
corrupt/malicious savestate claiming an enormous count would drive an
effectively unbounded allocation loop. Clamp it to how many block
records could plausibly still fit in the remaining stream data.
2026-08-11 08:43:52 +02:00
Henrik Rydgård 6a7c9d35aa GameManager: check libzip return values before using their results
zip_get_name() returns NULL on a corrupted central directory entry;
several call sites passed its result straight into endsWith(),
std::string construction, or the fileAllowed() lambda (which calls
HasParentDirComponent()/strchr()/strrchr() on it) without checking -
undefined behavior on a malformed/malicious zip (game/homebrew/texture
pack installs, or a downloaded ISO zip).

zip_stat_index() can likewise fail, leaving its output zip_stat
uninitialized; ZipReadFileByIndex, DetectTexturePackDest, and
ExtractFile all used the result (zstat.size, to size a buffer or check
a texture-pack size limit) without checking the call succeeded first,
using an initialized zip_stat via zip_stat_init() so the failure case
reads a known-zero size instead of stack garbage that could drive a
huge buffer.resize() or bypass the texture-pack size limit check.

ZipReadFileByIndex also didn't check zip_fopen_index()'s return before
passing it to zip_fread().
2026-08-11 08:43:52 +02:00
Henrik Rydgård afbc73bb68 AtracTrack: fix OOB reads and unbounded allocation in header parsing
These parsers run on fully game-controlled buffers (reachable via the
various sceAtracSetData*/sceAtracSetHalfwayBuffer* HLE calls), so a
malicious/malformed game can supply arbitrary bytes here:

- AnalyzeAtracTrack's RIFF chunk-walking loop computed `offset +=
  chunk + (chunk & 1)` (all in 32-bit) and only bounds-checked
  afterwards. A crafted chunk size could wrap `offset` (and the
  `offset + 12` check itself) around, bypassing the bounds check
  entirely - Read32(), whose offset parameter is a plain `int`, would
  then read from a wild pointer far outside the buffer. Do the
  validation in 64-bit before mutating offset, mirroring the pattern
  already used by the newer ParseWaveAT3 parser.
- AnalyzeAA3Track validated `size >= tagSize + 36` but then read up to
  relative index 35 after rebasing by 10+tagSize - i.e. absolute index
  tagSize+45, 10 bytes past what was actually checked.
- The SMPL chunk's loop count (checkNumLoops) was only checked for
  being negative, not bounded against the chunk's actual size, so a
  crafted value near INT_MAX would drive an unbounded (up to ~2
  billion entry) vector::resize() - an easy crash/OOM. The same
  unclamped value also let the fill loop below run past the end of
  the chunk, since its bound compares the loop counter to chunkSize
  rather than the byte offset actually being advanced (24 bytes/loop).
  Clamping checkNumLoops to what the chunk can actually hold fixes
  both.
- ParseAA3Headers checked for at least 9 bytes but the "ea3"/"id3"
  branch it guards reads up through byte index 9, needing 10.
2026-08-11 08:43:52 +02:00
Henrik Rydgård 3c7a323e99 Merge pull request #22074 from hrydgard/httpclient-fixes
HTTP Client cleanup and fixes
2026-08-11 08:36:07 +02:00
Nemoumbra 2c7d4c5613 Small HTTPClient cleanup 2026-08-11 04:12:46 +03:00
Nemoumbra c797ac200c Fixed IPv6 formatting 2026-08-11 04:02:16 +03:00
Nemoumbra c4fab7d1dc Removed unnecessary waiting 2026-08-11 03:50:48 +03:00
Henrik Rydgård 846fcf3e2c Merge pull request #22071 from hrydgard/webserver-timeout
Fix unexpectedly long timeouts in RegisterServer
2026-08-10 22:58:22 +02:00
Nemoumbra 5e9edb83ad Fixed passing 'timeout' to 'maxTries' in WebServer 2026-08-10 23:31:16 +03:00
Henrik Rydgård 53627fa9ef Merge pull request #22070 from James2022-rgb/feature/font-hw-blend
sceFont: match hardware's glyph blending and destination combine
2026-08-10 14:26:01 +02:00
James0124andClaude Opus 5 8afa18c21c sceFont: match hardware's glyph blending and destination combine
sceFontGetCharGlyphImage_Clip stored the glyph over the destination buffer.
Hardware adds it with saturation instead, so on hardware a glyph never erases
what was already in the buffer - the transparent parts contribute zero - while
we cleared them to 0. Text drawn as overlapping glyphs lost the earlier ones.

Measured on a PSP-1000 against a dumped ltn0.pgf, sweeping the pen position's
fractional part, both 4bpp and 8bpp, and several destination pre-fill values so
that "wrote a 0 here" could be told apart from "did not write here":

- The result is min(dst + blend, max), not blend.
- The two horizontal weights are rounded in opposite directions, down for the
  left neighbour and up for the pixel itself. That is not a free choice: it is
  what makes the pair sum to exactly the format's full scale whenever both
  samples are full ink, for every fraction. Rounding the combined sum once, as
  before, disagrees with hardware on about a fifth of the blended pixels.
- 4bpp blends the raw nibble and 8bpp the value swizzled to 8 bit. Blending at
  8 bit and narrowing afterwards is not the same thing and misses ~0.6%.
- The fractional part of yPos64 is discarded entirely. There is no vertical
  blending and the rectangle never grows downwards, so renderY2 loses its +1
  and the two render loops collapse into one - xFrac == 0 needs no special
  case, the first term vanishes and the second collapses to exactly b.

Only PSP_FONT_PIXELFORMAT_4 and _8 render at all. _4_REV, _24 and _32 return 0
and leave the buffer completely untouched, which is also the answer to the
"not sure how to make these produce an image" note in pspautotests'
charglyphimageclip.cpp. They are skipped now, with a report so that a game
relying on them can be found - only one buffer configuration was measured.

Verified with pspautotests: the new charglyphimagexfrac goes from 388 of 601
differing lines to passing, and charglyphimage and charglyphimageclip improve
from 57 differing lines to 3. No other font test changes. The remaining 3 are
a separate, pre-existing issue with bytesPerLine of 0 and 1.

Not covered: clip rectangles that actually clip, negative pen positions, and
the shadow glyph entry points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:49:55 +09:00
Henrik Rydgård 914e4e4cab Merge pull request #22069 from hrydgard/read-u32-cleanups
Tech debt: Start cleaning up HLE memory access
2026-08-10 12:09:50 +02:00
Henrik Rydgård ca9c4a0bd5 Merge pull request #22061 from hrydgard/fix-net-file-audit-bugs
Common audit: Fix net/file bugs
2026-08-10 12:08:47 +02:00
Henrik Rydgård feefbfcba0 Apply Nemo's feedback 2026-08-10 11:29:02 +02:00
Henrik RydgårdandClaude Sonnet 5 df751d5571 URL/VFS: fix latent pointer/index UB on empty or tiny input
UriDecode() formed SRC_END - 2 unconditionally, a pointer before the
start of the buffer (UB) for a 0- or 1-byte input. IsLocalAbsolutePath()
indexed path[0]/path[1] on a std::string_view with no bounds check,
UB for an empty path (path[0]) or a 1-byte path on Windows (path[1]).
Neither was known to crash in practice, but both are real UB flagged
by hardened/UBSan builds and easy to trigger (e.g. an empty query
string, or listing the VFS root).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4QAoxV2KY7ek4PcZw3WvY
2026-08-10 11:26:39 +02:00
Henrik RydgårdandClaude Sonnet 5 37cea65e0e WebsocketServer: cap message size, avoid UB on empty-vector payloads
ReadFrame() accepted a 64-bit client-supplied payload length with only
a top-bit check, and ReadPending() immediately resized a buffer by it
before any data had arrived - a single frame claiming a huge length
(reachable via the WebSocket debugger endpoint) could trigger a
multi-exabyte allocation attempt. Now rejected up front (both the
single frame and the fragmented-message total) against a 64MB cap.

Also replaced &payload[0]/&vector[0] with .data() in the send/receive
paths - operator[] on a possibly-empty vector (e.g. an empty PING) is
UB even when the result is never dereferenced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4QAoxV2KY7ek4PcZw3WvY
2026-08-10 11:26:24 +02:00
Henrik RydgårdandClaude Sonnet 5 6ee6641fe5 ZipFileReader: guard against implausible/overflowing declared sizes
ReadFile()/ReadSingleFileFromZip() allocated/resized directly off a
zip entry's declared uncompressed size with no sanity check. A
crafted size near UINT64_MAX would wrap ReadFile()'s "size + 1" to 0,
allocating almost nothing while zip_fread() still writes the full
declared size into it - a length-field-driven heap overflow from a
malicious zip/texture pack. Both now reject entries above a generous
4GB cap. Also fixes GetFileInfo() reading zstat.name[strlen(name)-1]
unchecked, which underflows to SIZE_MAX for a zero-length entry name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4QAoxV2KY7ek4PcZw3WvY
2026-08-10 11:26:24 +02:00
Henrik RydgårdandClaude Sonnet 5 99ab8b81ca HTTPHeaders: fix OOB alloc from a request line with no trailing space
ParseHttpHeader() used strchr(buffer, ' ') unconditionally as endptr,
even though the parser explicitly supports HTTP/0.9-style requests
with no trailing space/version (type = SIMPLE). A request line like
"GET /" with no space made strchr return null, and nullptr - buffer
truncated to a garbage length driving new[]/memcpy. Falls back to the
end of the line when no space is found, and clamps param_length to
avoid a similar issue when '?' appears after the (missing) space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4QAoxV2KY7ek4PcZw3WvY
2026-08-10 11:26:24 +02:00
Henrik Rydgård 6540a29180 Merge pull request #22067 from hrydgard/networking-work
Fix some WinSock error code problems, fix error logic in InputSink (networking)
2026-08-10 11:25:46 +02:00
Henrik Rydgård dd87815c07 Merge pull request #22068 from SternXD/winmobile
UWP: Purge Windows Mobile
2026-08-10 11:25:23 +02:00