Replace the plaintext FileAuthTokenStore binding with platform-specific
encrypted stores behind the same AuthTokenStore interface, wired via a new
expect/actual authTokenStoreModule.
Android: EncryptedSharedPrefsAuthTokenStore backed by EncryptedSharedPreferences
with a Keystore-backed AES256_GCM master key (androidx.security:security-crypto).
Desktop: EncryptedFileAuthTokenStore writes the token-map JSON as AES/GCM/NoPadding
to the config directory. The key is derived (PBKDF2WithHmacSHA256) from the OS user
name and home dir plus a static salt, with no key file on disk, so a copied token
file is useless on another machine or user. A random 12-byte IV is prepended per
write and owner-only POSIX perms are applied best-effort. Decryption failure is
treated as no tokens rather than crashing. This guards against casual disk
scraping and off-machine copies, not same-user local malware that can re-derive
the key.
iOS: still uses the plaintext file store pending a Keychain-backed implementation
(TODO marker in the iOS binding).
Migration: a legacy plaintext auth_tokens.json from an intermediate build is
imported into the encrypted store and deleted on first access; existing encrypted
tokens win on key collision so a stale plaintext entry cannot clobber a fresh
session.
Auth bearer/refresh tokens were stored in cleartext inside the per-workspace
server.json, which on F-Droid builds can be relocated to public external
storage, exposing the secrets.
Move only the secret token fields into a new app-private, account-keyed
AuthTokenStore. server.json stays per-workspace under projectsDir with its
non-secret fields { ssl, url, email, userId } so switching the project
directory still auto-loads that workspace's configured server/account.
- AuthTokenStore interface + FileAuthTokenStore: a single JSON file under
getConfigDirectory() holding accountKey ("url|userId") -> AuthTokens. The
store is keyed by account so two workspaces on the same account share the
login; tokens are per-machine and do not travel with the workspace folder.
The interface lets an encrypted backing be substituted later.
- PersistedServerSettings: tokenless DTO written to server.json. The in-memory
ServerSettings shape is unchanged, so token readers (Http auth/refresh,
AccountUseCase, AccountReauthUseCase) keep reading .bearerToken/.refreshToken.
- store writes the tokenless DTO and puts tokens in the store; load composes
tokens back in; remove deletes server.json and clears the account's tokens.
- Migration: loading a legacy server.json with inline tokens moves them into
the store and rewrites the file tokenless. A token-store write failure is
logged and the in-memory session is preserved (never logs the user out).
Reuses the existing readJsonOrNull/writeJson filesystem helpers, which also
tolerate corrupt/missing files. Writes route through the guarded
ContainedFileSystem; the config dir is an allowed root.
downloadEntry asks the server for a specific entity id but took the
returned entity's identity and type entirely from the response, then
keyed storeEntity and recordSyncedHash off the server-chosen values.
F-12 (id forgery): a hostile server asked for entity N could answer with
id M and attacker content. The client would overwrite/forge local entity
M and record the forged hash as the conflict baseline, concealing the
tampering and re-propagating it to the user's other devices.
F-13 (type confusion): the subtype was chosen purely from the server's
entity-type header, so the server could steer the same id into the wrong
repository.
Add two checks right after reading the server entity, before dispatch:
- reject any response whose id differs from the requested id, and
- reject a type mismatch when the client already owns the id
(findEntityType non-null). When the client does not yet own the id, a
legitimate new-entity download has no local type to compare against, so
it is allowed through.
A rejected entity returns CResult.failure, which the fullEntityTransfer
loop already tolerates without aborting the sync or recording a hash, so
one bad entity skips cleanly. The synced hash is now recorded under the
requested id, which provably equals the server's id once the guard passes.
Image read, delete, reId, and sync hardcoded the "jpg" extension, so a non-jpg entry image was never found: it orphaned on delete, failed to re-sync, and could not round-trip. Images are now located by their actual on-disk extension (findEntryImagePath), and setEntryImage preserves the source file's extension instead of always writing .jpg.
The accepted extensions are restricted to the raster formats Coil renders on every target (Android BitmapFactory + desktop/iOS Skia): jpg, jpeg, png, webp. heic/heif are dropped because they decode on Android but not desktop/iOS, which would store an image that cannot be displayed. This set also backs the F-1 sync image-extension allowlist, so narrowing it tightens what a malicious server may write.
Replaces the raw platform FileSystem singleton with a ForwardingFileSystem
decorator, ContainedFileSystem, that fail-closes every mutation (sink,
appendingSink, atomicMove source+target, delete, createDirectory,
createSymlink, openReadWrite) to the app's managed storage: the cache,
config, and current projects directories. A write outside all roots throws.
Reads, listings, and metadata are deliberately not guarded.
Allowed roots are supplied as a lambda re-evaluated per check because the
projects directory is user-relocatable at runtime. The projects-dir lookup
is cycle-safe: it prefers the built GlobalSettingsStore, falls back to the
settings datasource, then to the default dir, so config-root writes during
bootstrap (before the store exists) are not blocked. Directory creation uses
a relaxed rule that also permits a root's ancestors, so a clean first run can
scaffold the roots while a sibling escape stays blocked.
Consumers that legitimately write outside managed storage get a raw,
unguarded FileSystem via the RAW_FILESYSTEM qualifier:
- ExportStoryUseCase (desktop/iOS export to a user-chosen path)
- DesktopExternalFileIo (desktop docx/pdf export)
- AndroidPlatformSettingsComponent (storage relocation moves projects into
the new managed directory before it is registered as a root)
A server-synced project name can contain `/` and `..` (the validator
permits them; `toLocalSafeName` leaves an already-valid name unchanged),
and two cache-path builders used `projectDef.name` raw as a path segment:
statistics and the reference index. A crafted name could place the cache
file outside the per-project cache directory (the same traversal class as
F-1), and on Windows the raw `/` could break path construction outright.
Both builders now encode the name with `ProjectsRepository.encodeForFilename`,
matching how the on-disk project directory already encodes it, and each
datasource gains an `isWithin(cacheRoot)` backstop before every write,
createDirectories, and delete.
This orphans any old raw-named cache directories on existing installs.
That is harmless: statistics and the reference index regenerate on demand.
A malicious or compromised sync server could supply crafted entity
fields that were used verbatim as filename components when writing
downloaded entities to disk, with no validation on the sync path
(unlike the local-create paths). Embedded `/` or `..` escaped the
intended directory and, since file contents are attacker-controlled,
gave a write-anywhere primitive on the unsandboxed Desktop target.
Two sinks were affected:
- SceneDraftEntity.name flows into the draft filename in
SceneDraftsDatasource.insertSyncDraft.
- EncyclopediaEntryEntity.Image.fileExtension flows into the entry
image filename in EncyclopediaDatasource.writeEntryImage.
Defense in depth, two layers:
Layer 1 (synchronizer boundary, graceful per-entity skip):
- ClientSceneDraftSynchronizer.storeEntity rejects any draft whose
name fails the existing validDraftName check, logs a warning, and
returns false so only that entity is skipped (no synced hash is
recorded, so sync state is not poisoned and the run is not aborted).
- ClientEncyclopediaSynchronizer.handleImage validates the image
file extension against a known-image allowlist (ALLOWED_IMAGE_
EXTENSIONS); a disallowed extension skips just the image write while
the entry itself is still stored.
Layer 2 (containment backstop): a new Path.isWithin(root) helper
(okio normalized()) is asserted right before the write in both
datasource sinks, hard-failing any path that escapes its intended
directory. With Layer 1 in place this should never fire for the known
sinks; it fail-closes any future sink that forgets validation.
Tests (TDD, confirmed failing before the fix): traversal draft names
and image extensions are blocked with no file written outside the
target directory; isWithin rejects `..`, absolute, and sibling-escape
paths; happy-path drafts and jpg/png images still round-trip.
Every project-scoped client API built its request path by raw string
interpolation of projectName, e.g. "/api/project/$userId/$projectName/begin_sync".
That string reached the shared url() builder whose only path handling was
pathSegments = path.split("/"). Because the split ran on the already-interpolated
string, a projectName containing "/" was split into extra discrete path segments
and a ".." survived as a literal traversal dot-segment, so the outbound request
could target a different endpoint than the {userId}/{projectName}/{action}
template intended (e.g. a malicious sync server returning a project named
"p/../../../api/account/test_auth").
The shared ProjectNameValidator permits "/", "\" and "." (they are encoded to
disk-safe lookalikes only when used as a directory name), so a server-supplied
project name persists verbatim and then injects into every subsequent
project-scoped request under the same host with the bearer token attached.
Fix: each dynamic value is now percent-encoded into a single opaque path
segment via String.encodeUrlPathSegment() before interpolation, and the sink
sets encodedPath directly. Embedded "/" becomes %2F so it cannot create extra
segments, and an all-dots segment is encoded to %2E so a ".." name cannot act as
a traversal segment. The validator is intentionally left unchanged: tightening
it to reject "/" or "." would break syncing for already-valid existing project
names, so encoding is the backward-compatible fix and the on-disk
encodeForFilename behavior is untouched.
Adds a MockEngine test asserting a malicious projectName collapses to a single
encoded segment in the outbound URL across ProjectDataApi, ServerProjectApi and
WritingActivityApi.
ProjectRootActivity was exported and resolveProjectDef deserialized a caller-supplied ProjectDef whose free-form path was used verbatim for all project file I/O, letting any installed app root the editor at an attacker-controlled directory (confused deputy, CWE-926/CWE-22). Resolve the target project by name against on-disk projects only, drop the serialized EXTRA_PROJECT path extra, and mark the activity exported=false; it is only launched in-process via explicit intents and PendingIntents from widgets and shortcuts.
loadTimeline caught only FileNotFoundException, so a present-but-malformed timeline.toml crashed the load. Extend the catch to SerializationException, IllegalArgumentException, and IllegalStateException, returning an empty timeline as with the missing/blank-file cases.
A malformed note file, or one with a non-integer id, threw uncaught from tomlkt (IllegalStateException/IllegalArgumentException beyond SerializationException) and aborted the entire notes load. loadNotes now skips unparseable files via readTomlOrNull and loads the rest.
Exiting a project with auto-sync on runs requestClose(), which queued
CloseConfirm.Sync and tore down open editors before the sync ran. Scenes,
notes, and encyclopedia entries flagged unsaved edits via shouldConfirmClose(),
but TimeLineComponent returned emptySet(), so an in-progress timeline event
edit was silently discarded with no warning.
Wire TimeLineComponent.shouldConfirmClose() to the existing isEditingAndDirty()
check and add a CloseConfirm.Timeline confirmation dialog on Android/common and
desktop, mirroring the notes/encyclopedia pattern.
Closes#588
Backup filenames used an ad-hoc, lossy `space<->underscore` transform while
project directories use encodeForFilename. Two failures fell out of the
mismatch: names with underscores never matched their project on read (the
backup vanished from Manage Backups), and names with now-allowed OS-forbidden
characters produced filenames that can't be written on Windows/Android, so the
backup was silently never created.
Write backups using the same encodeForFilename as the project directory, and
match a file to a project by comparing its name-key against that encoding, with
the legacy `space->underscore` name accepted as a fallback so backups written
by older clients are still found.
Scene and group titles are stored wrapped as `order~name~id`, so a leading
dot or a Windows reserved word (CON, PRN, COM0-9, LPT0-9, ...) can never
collide on disk for them — yet name validation rejected them as if the title
were a raw filesystem basename. Project names, which become directories
verbatim, keep the strict rules.
- ProjectNameValidator.validate gains usedAsRawFilename (default true); the
leading-dot and reserved-name checks now only apply to raw filenames.
Trailing dot/space stay rejected for everyone (the on-disk encoder strips
them). Reserved set extended with COM0/LPT0.
- Thread the flag through ProjectsRepository.validateFileName and
SceneRepository.validateSceneName.
- Consolidate the six naming dialogs onto a shared rememberNameValidation
hook keyed by NameKind (Project vs SceneItem), so strictness lives in one
place instead of a per-call-site boolean.
Promote the client's project/file-name validation rules out of :common's
ProjectsRepository and into a shared ProjectNameValidator in :base, resolving
the long-standing TODO on the old length-only stub.
Project URLs are now /story/{slug}-{id} (and /a/{penName}/{slug}-{id}), where the
trailing id is a stable 6-char base62 hash of the project's uuid and the slug is
purely cosmetic and never parsed back. This dissolves the slug round-trip bug:
the slug can be as pretty as we like because resolution matches the embedded id
against the user's projects, not the name. A bare /story/{id} (no slug) also
resolves, for short URLs.
The importer split on a single exact heading level and treated every
other level as plain body, so round-tripping a document (including
Hammer's own export of `# Title` + `## Chapter`) collapsed everything
into one scene when H1 was chosen and produced a spurious "Untitled"
scene for the title when H2 was chosen.
Fold the heading stream into a hierarchy instead: headings shallower
than the chosen level open groups, headings at the chosen level open
scenes, and deeper headings stay as scene body. Leading content that is
only headings/whitespace no longer becomes an Untitled scene. Heading
detection now tolerates a BOM and up to three spaces of indent, and
scene bodies are trimmed both ends so the blank line after a heading
does not leak into content.
Fixes#578
Order backups by file modification time instead of the date parsed from
the filename. Backups written before the date-format fix used a broken
format (ISO week-based-year YYYY and 12-hour hh), so late-December-2025
backups were stamped months in the future. Sorting by that encoded date
made those phantom-future files look newest, so culling kept them and
deleted the genuinely newest backups instead.
Also broaden the backup filename pattern so project names containing
characters outside [a-zA-Z0-9_] (apostrophes, hyphens, non-ASCII) are
recognized, and stop date parsing from throwing so one malformed
filename can't blank the entire backup list.
Mark the scene-tree state types @Immutable/@Stable and move them onto
kotlinx.collections.immutable so Compose can skip recomposition when the
tree is unchanged: TreeValue.children becomes ImmutableList, SceneSummary
.hasDirtyBuffer a PersistentSet (sourced as such from SceneContentRepository),
and SceneList.State.archivedScenes an ImmutableList. Also cache ImmutableTree
.nodeIndex/hashCode lazily and gate compose-compiler stability reports behind
the composeCompilerReports property.
* Harden onSceneBufferUpdate to reduce from oldState
Read the scene summary from the getAndUpdate lambda's oldState argument
instead of a snapshot captured before the CAS, so the reducer stays a pure
function of its input and composes correctly if buffer updates ever run off
the main dispatcher.
A project name the server accepts can contain characters this client
rejects (e.g. #). Such projects failed local creation during account
sync and were silently skipped, so they never appeared on the client
and could not be deleted from it.
Map server names through ProjectsRepository.toLocalSafeName when
creating or renaming local projects from server changes, and log a
failed local create as an error rather than a warning.
Also plug a test leak: SceneEditorRepository{Archive,Other}Test
mockkObject ProjectsRepository.Companion without unmocking, which
globally forced validateFileName to succeed for any later test.
Add tests over a real datasource/fake filesystem for setEntryImage
(store and null-clears), loadEntryImage/getEntryImagePath round-trip,
calculateEntryImageHash, removeEntryImage (success and failure branches),
and ensureEntriesLoaded caching.
Add tests for reIdEvent, updateEventForSync (replace and append),
storeTimeline flushing in-memory edits, getTimelineEvent, and the
server-synced markForSynchronization branch.
Drive the repository over a real GlobalSettingsStore (mocked datasources)
and a mocked platform spell-check factory: loads a checker for the
configured locale, skips unsupported locales, reloads on a locale change,
ignores unrelated settings changes, plus the toSpLocale mapping.
Add tests for getBackupsForProject, deleteBackup (success, missing,
failure), cullBackups (over and under budget), and the real
createBackup/restoreBackup zip round-trip over a fake filesystem.
Cover ProjectDataRepository end-to-end over a fake filesystem (load
caching, user edits invalidating the project hash, sync-only updates),
fill out every branch of ProjectDataSyncOperation (no-op, fast-forward,
upload, and conflict resolve/abort paths) by driving real repository and
broker collaborators, and exercise the EntitySynchronizers facade's
type dispatch (get, findEntityType, reIdEntry phantom skip, conflict
routing) for all entity types.
* Expand test coverage for sync synchronizers and ImmutableTree
Add a Classical-style suite for ImmutableTree covering the previously
untested query API (indexOf, findBy, isAncestorOf, getBranch, coordinate
round-trips, iterator exhaustion, equals/hashCode).
Rewrite ClientEncyclopediaSynchronizer tests to drive real repository,
service, and datasource collaborators over a fake filesystem instead of
mocks, asserting observable on-disk state, image base64 round-trips, and
create-vs-update behavior.
Extend ClientSceneSynchronizer tests with createEntityForId,
deleteEntityLocal, archived-scene-from-server, store-content failure,
and reIdEntity branches.
Extend EntityTransferOperation tests with the download/heal branches:
not-modified, not-found remote delete, stale-hash forced-upload heal,
failed-store logging, and the onlyNew upload path.
* Fail entity transfer when a downloaded entity cannot be stored
downloadEntry returned CResult.success() even when storeEntity failed,
so a failed download was logged but never marked the transfer as
unsuccessful, masking sync failures from the operation's allSuccess
tracking. Return a failure result in that case so the transfer reflects
it.
* Add tier 2 sync coverage: uploadEntity, transfer + scene branches
Cover the EntitySynchronizer.uploadEntity base method end-to-end through
the encyclopedia synchronizer's classical harness (success, force, plain
failure, conflict resolution, and failed resolution), the remaining
EntityTransferOperation download/upload failure branches, and the scene
group move-parent and unarchive-on-active paths.
The markdown editor only exposed inline styles through format-bar buttons;
the underlying composetexteditor library handles editing/navigation shortcuts
(Ctrl+C/V/X/Z/Y, arrows, etc.) but has no bindings for bold/italic, so those
keys were simply dropped.
SceneDraftsDatasource.reIdScene moved the scene's drafts directory
unconditionally, so re-IDing a scene with no drafts threw
FileNotFoundException and failed the entire sync during ID conflict
resolution.
- UrlLauncher: open URLs via UIApplication.openURL (fixes update dialog button)
- DateTimeUtils: format Instant/LocalDateTime properly (was empty / used now())
- NetworkConnectivity: real reachability via NWPathMonitor with 2s timeout
- BackupManagerService: present iOS share sheet via UIActivityViewController
- FocusModeService: documented as noop (iOS has no public DND API)
On app open, sync made ~4 HTTP round-trips per project even when nothing
had changed. Add a batched pre-sync probe: the client sends a project-wide
content hash for each eligible project in one request, and the server
returns which ones still match so the client can skip syncing them.
- base: ProjectContentHasher (entity hashes + project-data hash, computed
identically on client and server) and probe request/response DTOs
- server: POST /projects/{userId}/sync_probe, recomputing each project's
hash from stored state; skips projects with an in-flight sync session;
400 on a malformed body
- client: cache the hash in the project journal (written at FinalizeSync,
invalidated on any content mutation via one chokepoint); probe is
best-effort and degrades to a full sync on any failure
- backstop: never skip a project with pending entity or project-data work,
so a stale cache can't drop a change
Writing activity is excluded by design (per-device, conflict-free).
Tests: ProjectContentHasher, client/server probe units, and an e2e suite
covering client/server hash agreement, the session gate, auth, and 400s.
Server reclaims a project sync session only for the originating install
(derived from the auth token), so a leaked or cancelled session no longer
blocks the owner; a different active install is still rejected. Client
ends sessions under NonCancellable so end_sync isn't dropped when a sync
coroutine is cancelled.