Commit Graph

10 Commits

Author SHA1 Message Date
Anso d8b6f8cf3b feat(stack-files): force-text override for misidentified binary files (#1215)
* feat(stack-files): force-text override for misidentified binary files

The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.

readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.

SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.

Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.

* fix(stack-files): guard the binary-override path against oversized files

The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.

Two reinforcing changes:

- Initial load now checks result.oversized before result.binary, so a
  file that is both oversized and has binary bytes in the 8 KB probe
  shows the Download panel rather than the binary panel. The size
  signal stays in front of the operator and the override button never
  surfaces for a file that cannot be safely opened inline.

- The handleForceText handler now respects result.oversized on the
  refetch and transitions to the Download panel instead of clearing
  isBinary and copying result.content ?? '' into Monaco.

Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.

Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
2026-05-25 01:38:04 -04:00
Anso c2357ec534 fix(stack-files): symlink-aware delete and chmod (#1214)
deleteStackPath now lstats the leaf and unlinks the link entry itself
when it is a symbolic link, so the file the user clicked on in the tree
is what gets removed (the linked target stays intact). chmodStackPath
rejects with LINK_CHMOD_UNSUPPORTED on a symlink rather than silently
mutating the target's permissions; Node's lchmod is macOS-only and
following the link is the bug being fixed here.

Path-component symlinks are still resolved via the existing
resolveSafeStackPath, so a symlinked parent that escapes the stack dir
still surfaces SYMLINK_ESCAPE before the leaf is inspected.

Service-level tests cover delete on internal-target / external-target /
broken / dir-target symlinks, chmod rejection on symlinks (including
the broken case), and non-symlink regression checks. Route-level tests
pin the 409 LINK_CHMOD_UNSUPPORTED mapping and the link-only-delete
behaviour. The describe blocks are platform-gated; Windows symlink
creation needs admin/developer-mode and is skipped along with the
existing SYMLINK_ESCAPE test.

Docs updated to describe both behaviours in plain product terms.
2026-05-25 01:30:00 -04:00
Anso fcf2222604 feat(stack-files): cap directory listings at 1000 + add file-tree filter (#1208)
* feat(stack-files): cap directory listings at 1000 + add file-tree filter

The file-tree route returned every entry in a directory unbounded.
A logs/ or data/ subfolder with rotated artifacts could produce a
multi-megabyte response and a frontend cap at 500 entries silently
hid the rest with no way for the user to find a specific file.

The list route now caps the response at 1000 entries (the audit's
recommended bound), advertises the unfiltered total via
X-Total-Count, and sets X-Truncated when truncation happened. The
service exposes both a bare-array listStackDirectory (unchanged
contract for callers that just want the array) and a paginated
listStackDirectoryPage that returns {entries, total, truncated}.

The FileTree now offers a search input above the scroll area that
filters loaded entries by name (case-insensitive substring). Clearing
the filter restores the full listing. A non-matching filter shows a
short hint instead of an empty pane. The client-side MAX_ENTRIES
matches the server cap so a perfectly-sized directory never shows
the truncation hint.

* fix(stack-files): filter keeps parent dirs when loaded descendants match

The original filter applied per-render-level inside renderEntries, so a
parent directory whose name did not match was filtered out even when one
of its already-loaded children did. The match was then unreachable: the
parent had been removed from the visible list and its children never got
a chance to render.

Compute matching-descendant once per directory by walking the loaded
dirContents map (no extra fetch, bounded by what the user already
expanded). Keep ancestors of any match in the visible list. Auto-expand
those ancestors for the duration of the filter so the match comes into
view without a manual click on every parent.

Filter scope is still 'what is already loaded'; unexpanded subtrees do
not contribute to ancestor-keep until the user expands them. Two new
tests pin both behaviours.
2026-05-25 00:03:09 -04:00
Anso 4964320f50 fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag

PUT /api/stacks/:name/files/content previously did a blind write; two
operators editing the same script lost one of the saves with no
warning. The compose-file editor already had mtime optimistic
concurrency (PR #1183); this brings the file-explorer write path to
the same shape.

GET /files/content now also returns mtimeMs and sets a weak ETag header
derived from the stat. The matching PUT reads If-Match, asks
FileSystemService.writeStackFileIfUnchanged to compare against the
live mtime, and returns 412 PRECONDITION_FAILED with the current
content and mtime when the stale-write check fails. Successful writes
echo a fresh ETag so the client can pin the next save without re-GET.

readStackFile and writeStackFileIfUnchanged each open the file once
and stat+read through the same handle so the mtime returned to the
client matches the bytes that were sent, even if the file is replaced
between the two operations.

PUT without If-Match still succeeds (backward compatibility with
scripted clients that do not roundtrip the ETag). FileViewer now sends
the loaded mtime on save, updates its local mtime from the success
response, and on FileConflictError adopts the server snapshot as the
new baseline so the user's follow-up edit-and-save does not loop on
the same precondition.

* fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict

Two follow-ups from code review on the prior commit:

- writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is
  set and the target has been deleted. The caller was editing a file
  that no longer exists; silently writing the buffer to the void is
  wrong. The client adopts the empty snapshot as 'file is gone, start
  over' and the user keeps control of what to save next.

- The FileViewer conflict handler no longer overwrites the user's
  typed buffer with the server snapshot. It updates the baseline so
  the next save sends the fresh mtime, then leaves the editor content
  alone. The user sees their edits, the Save button stays enabled,
  and a follow-up click applies their changes on top of the new
  server version without silently destroying what they typed.

* fix(api): preserve default headers when caller supplies a headers field

apiFetch built defaultOptions.headers by merging Content-Type, x-node-id,
and the caller's headers, but then spread the unmodified fetchOptions
over defaultOptions at the outer level. The spread overwrote the merged
headers with the caller's bare headers, silently dropping Content-Type
on every request that supplied any custom header.

This was latent until the file-explorer save path started sending an
If-Match header. The Express body parser refused the PUT without
Content-Type, the route returned 400, the editor showed an error
toast instead of the success toast, and the Playwright save assertion
timed out.

Destructure headers out of fetchOptions before the outer spread so the
already-merged defaultOptions.headers survives. Add api.test.ts with
four regression cases pinning Content-Type, the If-Match merge,
x-node-id presence when active, and localOnly skip.
2026-05-24 23:44:24 -04:00
Anso c8b095b887 fix(stack-files): confirm before overwriting an existing upload target (#1204)
* fix(stack-files): confirm before overwriting an existing upload target

Same-name uploads previously truncated the existing file silently. A
user dragging a file with a name that matched an in-place file
destroyed the original with no warning and no undo.

The upload route now reads ?overwrite=0|1. When the flag is not set
and the target already exists, the server returns 409 FILE_EXISTS
and the original file is untouched. The frontend opens a confirm
dialog and retries with overwrite=1 on the user's approval; cancel
keeps the original.

A new pathExists helper on FileSystemService performs the existence
check through the same path-resolution barrier as the write so a
malicious relPath cannot bypass the conflict check. UploadConflictError
is exported so callers can distinguish the conflict case from generic
upload failures without parsing error strings.

* fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check
2026-05-24 23:17:22 -04:00
Anso 37b12379c1 fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files (#1202)
* fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files

Previously the per-stack file explorer treated PROTECTED_STACK_FILES
(compose.yaml, compose.yml, docker-compose.yaml/.yml, .env) as a UI
hint only. A direct API call from any user with stack:edit could
delete or rename compose.yaml and break the stack irrecoverably
because the next deploy would fail to find a compose file and the
write was unrecoverable without a DB backup. The frontend
DeleteFileConfirm enforced a type-to-confirm gate but a stale UI or a
scripted client bypassed it.

FileSystemService now refuses the destructive ops at the service layer
with a new PROTECTED_FILE error code that the route layer surfaces as
409. The compose-editor save path (PUT /files/content) and the
upload-overwrite path (POST /files/upload writing a same-named file)
remain unblocked because both are legitimate ways to update
compose.yaml. Tests pin the allowed paths so a future tightening can
not silently regress them.

The protection is scoped to entries at the stack root; subdirectory
files happen to share a protected name (e.g., a snapshot under
backups/compose.yaml) are not blocked because compose CLI only reads
the root file. A trailing-slash bypass is closed by stripping trailing
separators before the basename check.

Frontend DeleteFileConfirm needs no change. Its existing toast.error
surface renders the friendly server message.

* fix(stack-files): drop polynomial regex from protected-file helpers

CodeQL js/polynomial-redos flagged the /\/+$/ pattern used to strip
trailing slashes from relPath in isProtectedRelPath and
protectedFileError. The regex is bounded in practice (the upstream
validator rejects '//' anywhere in the path) but the static analyzer
cannot follow that dataflow guarantee and would have flagged any
future caller that skips the validator.

Replace the two callsites with a small stripTrailingSlash helper that
uses endsWith + slice. Bounded O(1), no regex, no analyzer alert. The
inline comment documents the upstream invariant so a future reader
does not reintroduce the /+ quantifier.
2026-05-24 23:06:05 -04:00
Anso 4c28b37a59 fix(stacks): require stack:read on file explorer GET routes (#1200)
The four file-explorer GET endpoints (list, content, download,
permissions) previously relied on auth alone. Their write-side siblings
required stack:edit, so the read path was the only file-explorer surface
without an explicit capability check. The shipped roles all carry
stack:read globally so behaviour is unchanged today, but adding the
guard prevents a future role definition from silently inheriting
unrestricted file reads, and it brings the file-explorer reads in line
with gitSources and stackActivity which already gate on stack:read.

The frontend Files tab trigger, panel, and the anatomy-panel "Open
Files" affordance now render only when the user holds stack:read for
the active stack. An effect canonicalises activeTab back to 'compose'
if the user lands on 'files' without permission, so a denied user
cannot end up staring at an empty panel.
2026-05-24 22:58:28 -04:00
Anso 535023b350 feat(files): open stack file explorer to every tier (#1144)
* feat(files): open stack file explorer to every tier

Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.

The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.

* docs(files): treat download as a read action, not a write

Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.

* test(e2e): align stack-files spec with the new tier rule

The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.

Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
2026-05-21 19:35:31 -04:00
Anso 69b6ac1f3b fix: harden stack file explorer operations (#1028)
* fix: harden stack file explorer operations

* fix: update Docker toolchain to Go 1.26.3

* fix: repair Dockerfile tr argument split across lines

* fix: bump protobufjs to clear npm audit high-severity advisories
2026-05-12 15:49:51 -04:00
Anso 801a098a5b feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
2026-04-26 13:05:19 -04:00