Skip to content

Filesystem corruption on removable media: an app cannot flush its writes (FlushFs is ungrouped) #9

Description

@ObjSal

Environment: Foundation SDK 1.0.0, KeyOS 1.4.0-beta1, third-party
(publisher-signed, sideloaded) app, publisher trusted via Settings → Apps →
Allowed Publishers.

Summary

A sideloaded app exported an OpenPGP secret key to the Airlock, the user
moved it to the USB/SD volume, the device's own file UI listed it — and on a
computer the file is 849 bytes of garbage, not a key. A second entry on the
same volume is corrupt at the directory level: readdir returns it, and every
operation on it fails with EINVAL.

The app did nothing unusual: open_file(CREATE)overwrite(bytes) → close.
It has no way to flush, because os/fs FlushFs carries no permissionGroup
and so resolves to Foundation-signed-only.

This is worse than losing the file. The export reports success, the file
appears with the correct size, and the contents are wrong
— for key material
the user carries away, that is discovered only when the key is needed.

Evidence, from the card as it sits

Three files written minutes apart on the same volume:

file written by size content
5CC2C119E6940DB7-public.asc device (02:53) 624 -----BEGIN PGP PUBLIC KEY BLOCK-----
5CC2C119E6940DB7-secret.asc device (02:55) 849 binary garbage
5CC2C119E6940DB7-secret-1.asc macOS GPG Keychain (03:09) 849 -----BEGIN PGP PRIVATE KEY BLOCK-----

The garbage file begins:

00000000: 5776 18a8 042b 55aa 0059 89de a2f1 1113  Wv...+U..Y......
00000010: ee35 5074 397f a4a6 29dd cfd8 8960 580b  .5Pt9...)....`X.

file(1) reports data. The size is exactly right for the armored key, so the
directory entry reached the medium while the data clusters (or the FAT chain
pointing at them) did not — reads return whatever those clusters held before.

And a fourth entry, 5CC2C119E6940DB7.asc, is unusable:

find      → ./5CC2C119E6940DB7.asc        (readdir yields it)
ls -1     → not listed
stat()    → Invalid argument (errno 22)

Its name bytes are clean ASCII (b'5CC2C119E6940DB7.asc'), so this is the
entry itself, not a name-encoding problem. It cannot be opened, renamed or
deleted through the filesystem; the user hit it when Finder refused to rename
another file onto that name.

Mechanism

layer call third-party?
file → fatfs Flush(handle)file.flush() ✅ grouped file-system.write-and-mutate
fatfs → disk cache same call; the block may sit in Disk's LRU cache as dirty
disk cache → block device FlushFsflush_disk()Disk::flush() ungrouped → Foundation-only

Disk::flush() (os/fs/src/disk/mod.rs) is the only code that walks the block
cache and writes dirty entries to the device. The only other trigger is
Disk::drop, i.e. unmount — and the Airlock unmount (MountAirlock(false)) is
ungrouped too. So an app has no way to force its own writes out, and no way
to know whether they landed.

Some writes do land regardless: Disk::write has a fast path where a full,
aligned block goes straight to write_blocks, and LRU eviction drains the rest
over time. That is what makes this so hard to attribute — data and metadata for
the same file take different paths, so a file can end up with a valid entry and
stale contents, which is exactly what the card shows.

What we would like

  1. Give FlushFs a permissionGroupfile-system.write-and-mutate
    would be consistent: an app permitted to write should be permitted to make
    its writes durable. A narrower "flush the volume this handle lives on" would
    serve equally well.
  2. Failing that, flush on CloseFile for third-party writers, making close
    a durability boundary. Slower, but safe by default.
  3. Independently: whatever the app is allowed to do, the filesystem should
    not be left in a state where a directory entry survives without its data
    ,
    or where an entry exists that stat rejects. If the volume can be pulled at
    any moment (it is removable, and the device gives an app no way to sync),
    ordering metadata after data would keep the failure to "file missing" rather
    than "file present and wrong".

Notes on reproducing

We could not reproduce this in the hosted simulator: a file written and
saved through an app appears in disk.dat — data and directory entry — with no
FlushFs call. That looks like a limitation of the simulator rather than
evidence the device is fine: its block device is a host file, so even an
evicted dirty block lands in the host's page cache and looks written.

The device evidence above is from a real card, still in the state described.
Happy to run any diagnostic that would help.

Secondary: the Airlock volume messages, and denials that abort the app

MountAirlock / FormatAirlock are ungrouped too, while the file-access pair
is grouped:

GetAirlockReadAccess  = { id = 104, permissionGroup = "file-system.airlock-files", approval = "grantOnFirstUse" }
GetAirlockWriteAccess = { id = 105, permissionGroup = "file-system.airlock-files", approval = "grantOnFirstUse" }
MountAirlock          = { id = 28,  approval = "autoAllow" }   # no permissionGroup
FormatAirlock         = { id = 29,  approval = "autoAllow" }   # no permissionGroup

So an app can be granted access to Airlock files it has no way to reach when
the volume is not already mounted. We would rather rely on a documented
guarantee than on mounting ourselves: if the system guarantees the Airlock
volume is mounted whenever USB does not own it, please say so
— we will drop
our mount calls and use GetAirlockWriteAccess alone. Nothing in the shipped
SDK states this, which is why the calls are there.

Worth noting where those mount calls came from: the hosted simulator mounts
nothing
, so the pattern ("mount lazily, format after a failed mount") was
written to make Airlock testable in the sim and then shipped to hardware.
Having the simulator mount its Airlock image the way the device does would stop
the next person writing the same code.

Finally, a denied message aborts the app rather than returning an error.
mount_airlock() sends via send_blocking_scalar, which is
try_send_blocking_scalar(..).unwrap(), so AccessDenied panics the process —
no xous_names warning, no chance for the app's own fallback. Our export code
already reads if mount.is_ok() { Airlock } else { Internal }; the panic is
what stops that fallback from running. FileSystem::flush() uses the
non-unwrapping try_ form and returns the denial as an fs::Error, which is
the behaviour we would want throughout. Routing the Airlock mount/format sends
the same way would let apps degrade instead of dying.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions