Files
NanoKVM-MIRROR/server/utils/mediapath.go
watermeko 5572e1de35 feat(storage): browse a medium and exchange files with the target machine (#957)
* feat(storage): browse a medium and exchange files with the target machine

The image menu and the download page are replaced by one storage page: it
lists the media the emulated USB disk can carry, mounts and ejects them, and
browses the files inside a medium over that same gadget, so the two machines
exchange files without going through the terminal.

Mediated media:

* Add the media API (list, state, mount, eject, delete, download, files) and a
  file browser on top of it, and keep the pre-2.6 image endpoints working as a
  compatibility layer over the same code.
* Add diskfs: an image is mounted through a loop device so the kernel's exFAT
  and FAT drivers read it, and the data partition is browsed in place, because
  the emulated disk already hands that very directory to the target machine.
* Keep the one-writer policy the read-only mounts introduced: an image is
  served read-only in every mode, and the whole data partition is remounted
  read-only here while the target machine is its writer, through the same
  SetDataWritable the virtual disk switch uses. The file manager refuses those
  writes itself as well, so a request does not depend on a mount state.
* Name the mounted medium and the USB device after the image file, cut to the
  SCSI inquiry product field and the USB product string, and restore the
  defaults when the medium is released.
* Close the HID devices around a gadget re-enumeration and reopen them after,
  since mounting and a forced eject drop them.
* Report transfers through one job slot with progress, queueing behind a
  running transfer, and a free space preflight before an upload starts. Image
  uploads keep the volume descriptor and partition table check, so an unusable
  image is rejected while it streams.

The shared folder view:

* Refresh it on a timer while the target machine owns the partition. The
  target's block level writes land in this device's page cache, which the
  mounted exFAT shares, so the file data here is already current - but the
  dentry and inode caches are not, and a file the target grew keeps the size
  it was first read with. Dropping those caches and pushing a storage event
  makes the web UI follow the target machine within a second of its own
  writeback. The partition's write counter cannot drive this: it only moves
  once this side writes its dirty pages back, which is tens of seconds later
  or never.
* Send the refresh notifications from their own goroutine, so a client that
  stopped reading cannot hold up the refresh loop.

The web UI:

* Add the storage page: the medium list with mount and eject, the file browser
  with search and sorting, uploads, downloads and deletes, and transfer
  progress.
* Gate writing on the target machine owning the disk - image uploads and file
  uploads alike - instead of letting the server answer with an error the page
  has to surface, and drop the medium rows' download and delete buttons there
  too.
* Give the shared folder, .iso and .img their own glyphs, and pick the file
  icon from the extension, so a listing can be read at a glance.
* Name the partition the way the storage page does in the desktop menu as
  well, instead of showing the API's untranslated string.
* Read the mounted medium in Settings - Device from the media state, now that
  the storage API it used is gone.
* Tell an ISO 9660 image apart from a filesystem that cannot be read at all:
  the kernel of this device carries no iso9660 support (CONFIG_ISO9660_FS), so
  the contents of an ISO cannot be listed while the image itself is served to
  the target machine like any other medium.

Localization:

* Translate the new strings for every locale, keep the Settings - Device
  strings the read-only mounts added, and drop the keys the retired image menu
  and download page left behind.

Chores:

* Remove the desktop image menu, the download page and the API they used.

* fix(storage): harden media file transfers

Serialize medium deletion with gadget attachment and restore /data write access after failed mount transitions.

Use available filesystem space for upload limits, refine virtual-device and shared-file handling, and update media path and installation test coverage.

Pin an image while it is streamed out, so a writable mount cannot start between the check and the last byte; sweep stale upload temporaries from the whole data partition at startup, which the file manager itself cannot delete; and preflight URL downloads against the response size, which only the upload path did.

Show dot files in the file browser again: only the two transfer temporary prefixes stay hidden, which is what the dot filter was for.

Delete folders recursively after the web UI confirms, refusing when an attached or mounted medium lives inside the directory; accept every .iso and .img upload by name instead of sniffing volume descriptors; and show image and file transfers in one progress display at the top of the storage page.

* fix(storage): make cache drops work and close delete and gadget races

- drop_caches only accepts 1..4 on 5.10: write 4 once to silence the log,
  then 2/3. Writing 6/7 failed with EINVAL, so the shared folder never
  saw what the target machine wrote.
- Resolve a delete path to its on-disk spelling (exFAT is case-insensitive)
  before checking it against the attached and loop-mounted media, refuse
  deleting a loop-mounted image or a folder holding an upload target, and
  fail closed when the disk state cannot be read.
- GetMediaState no longer fails when the virtual disk is off and lun.0 does
  not exist; browsing works with the virtual disk off.
- An explicit enable for the virtual disk compares against the disk being
  configured, so it leaves a served image alone; the settings toggle keeps
  the #937 semantics.
- Restore /data write access after a failed gadget script or network switch.
- eject clears the LUN file before resetting ro/cdrom, so a refused eject
  does not change the type of the medium still being served.
- Mount loop images with MS_NOSYMFOLLOW and open files with O_NOFOLLOW.
- Accept UDF images as optical media.
- Only refuse deleting transfer temporaries while a transfer runs; sweep
  stale ones in the background, by exact name, and not on a read-only /data;
  hide them from listings.
- Cap URL downloads without Content-Length at the free space and verify the
  received length.
- Encode Content-Disposition filename* strictly per RFC 5987.
- Restore image_test.go and add tests for the above.

* fix(web): keep the file browser and transfers consistent on the storage page

- Tag a listing with the medium and path it belongs to, so entries of the
  previous directory are never shown or acted on under a new path.
- Poll the desktop storage menu only while it is open.
- Show a dedicated message for a checksum mismatch.
- Bring back the USB / SCP / TF card hints behind a help button.
- Pick up a transfer already running when the page opens; make uploads
  cancellable; track concurrent uploads separately; let a failed job be
  dismissed; show a success state and guard the progress display by task.
- Handle request errors that were left unhandled.
- Dedicated confirmation for deleting a folder recursively.
- Treat UDF images as optical, send credentials on the image download
  pre-check, translate the remaining Hungarian string.

---------

Co-authored-by: Guoguo <i@qwq.trade>
2026-10-19 09:44:30 +08:00

234 lines
7.6 KiB
Go

package utils
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"unicode/utf8"
)
// MediaDirectory is the volume the emulated USB disk exposes: the target
// machine sees it as one removable drive, so nothing secret belongs here.
//
// It is split into two folders so both sides know what they are looking at:
// the shared folder is the drop box for exchanging files, the images folder is
// this device's own library of disk images.
const (
MediaDirectory = "/data"
ShareDirectory = MediaDirectory + "/share"
ImagesDirectory = MediaDirectory + "/images"
)
// maxNameLength is the FAT/exFAT limit in UTF-16 code units; counting runes is
// the closest practical approximation on the Go side.
const maxNameLength = 255
// forbiddenNameChars cannot appear in a name on any platform that mounts
// FAT/exFAT, so the file manager rejects them before the kernel sees them.
const forbiddenNameChars = `<>:"/\|?*`
// reservedNames are the DOS device names Windows still refuses, with or
// without an extension.
var reservedNames = map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
var (
ErrInvalidMediaID = errors.New("invalid medium id")
ErrInvalidMediaPath = errors.New("invalid media path")
)
// IsMediaFile reports whether name looks like a virtual medium. The emulated
// disk only ever exposes disk images, so the file manager refuses to touch
// anything else in MediaDirectory.
func IsMediaFile(name string) bool {
return IsISOFile(name) || strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".img")
}
// IsISOFile reports whether name is an ISO image, which is the one medium
// whose contents can be checked before it is mounted.
func IsISOFile(name string) bool {
return strings.HasSuffix(strings.ToLower(filepath.Base(name)), ".iso")
}
// ResolveMediaID turns a medium id, a path relative to MediaDirectory, into an
// absolute path. Every request naming a file on the device goes through here,
// so escapes (absolute paths, "..", separators where they do not belong) are
// rejected rather than patched up at the call site.
func ResolveMediaID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" || strings.ContainsRune(id, 0) {
return "", fmt.Errorf("%w: empty", ErrInvalidMediaID)
}
if strings.HasPrefix(id, "/") || strings.HasPrefix(id, `\`) {
return "", fmt.Errorf("%w: must be relative to %s", ErrInvalidMediaID, MediaDirectory)
}
rel := filepath.FromSlash(id)
if !filepath.IsLocal(rel) {
return "", fmt.Errorf("%w: %s escapes %s", ErrInvalidMediaID, id, MediaDirectory)
}
for _, segment := range strings.Split(filepath.ToSlash(rel), "/") {
if err := validateName(segment); err != nil {
return "", fmt.Errorf("%w: %s", ErrInvalidMediaID, err)
}
}
if !IsMediaFile(rel) {
return "", fmt.Errorf("%w: %s is not a disk image", ErrInvalidMediaID, id)
}
return canonicalMediaPath(MediaDirectory, rel)
}
// CanonicalMediaPath resolves an absolute medium path to the spelling used by
// the directory entries on disk. Like ResolveMediaID, it rejects paths outside
// MediaDirectory and names that are not disk images.
func CanonicalMediaPath(name string) (string, error) {
rel, err := filepath.Rel(MediaDirectory, filepath.Clean(name))
if err != nil || !filepath.IsLocal(rel) {
return "", fmt.Errorf("%w: %s is outside %s", ErrInvalidMediaID, name, MediaDirectory)
}
return ResolveMediaID(filepath.ToSlash(rel))
}
// CanonicalPathInside resolves rel, a path already checked by
// ValidateMediumPath, to the spelling the directory entries under root use.
// Unlike CanonicalMediaPath it accepts directories and arbitrary files: a
// delete has to compare what it removes against the canonical paths of the
// attached and mounted media, and "IMAGES" names the same exFAT directory as
// "images".
func CanonicalPathInside(root, rel string) (string, error) {
if rel == "" || rel == "/" {
return root, nil
}
return canonicalMediaPath(root, filepath.FromSlash(rel))
}
// canonicalMediaPath resolves every existing component to its on-disk name.
// exFAT lookups are case-insensitive, while Go string comparisons are not: an
// all-lower-case request and an upper-case request can name the same medium.
// Prefer an exact match so this also remains deterministic on a case-sensitive
// filesystem, and reject ambiguous folds instead of choosing between files.
// A missing component is kept as requested so callers can still create a new
// image or report the original not-found error.
func canonicalMediaPath(root, rel string) (string, error) {
segments := strings.Split(filepath.ToSlash(rel), "/")
current := root
for i, segment := range segments {
entries, err := os.ReadDir(current)
if err != nil {
if os.IsNotExist(err) {
parts := append([]string{current}, segments[i:]...)
return filepath.Join(parts...), nil
}
return "", fmt.Errorf("resolve medium path %s: %w", filepath.Join(root, rel), err)
}
matches := make([]string, 0, 1)
for _, entry := range entries {
if entry.Name() == segment {
matches = []string{segment}
break
}
if strings.EqualFold(entry.Name(), segment) {
matches = append(matches, entry.Name())
}
}
if len(matches) > 1 {
return "", fmt.Errorf("%w: %s has more than one case-insensitive match", ErrInvalidMediaID, filepath.Join(root, rel))
}
if len(matches) == 0 {
parts := append([]string{current}, segments[i:]...)
return filepath.Join(parts...), nil
}
current = filepath.Join(current, matches[0])
}
return current, nil
}
// ValidateMediumPath normalizes a path inside a mounted medium and returns it
// slash separated and relative to the medium root ("/" means the root itself).
// It takes the same shape the web UI shows, so a leading slash is accepted and
// stripped.
func ValidateMediumPath(p string) (string, error) {
if strings.ContainsRune(p, 0) {
return "", fmt.Errorf("%w: contains NUL", ErrInvalidMediaPath)
}
p = strings.TrimPrefix(strings.TrimSpace(p), "/")
if p == "" {
return "/", nil
}
clean := path.Clean(p)
if clean == "." {
return "/", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%w: %s escapes the medium", ErrInvalidMediaPath, p)
}
for _, segment := range strings.Split(clean, "/") {
if err := validateName(segment); err != nil {
return "", fmt.Errorf("%w: %s", ErrInvalidMediaPath, err)
}
}
return clean, nil
}
// ValidateFileName checks a single file name for FAT/exFAT compatibility. It
// is what an upload's browser-supplied name has to pass before it is written
// into a medium.
func ValidateFileName(name string) error {
return validateName(name)
}
func validateName(name string) error {
if name == "" {
return errors.New("empty name")
}
if name == "." || name == ".." {
return fmt.Errorf("reserved name %q", name)
}
if utf8.RuneCountInString(name) > maxNameLength {
return fmt.Errorf("name longer than %d characters", maxNameLength)
}
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return fmt.Errorf("name %q ends with a dot or space", name)
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("name %q contains a control character", name)
}
if strings.ContainsRune(forbiddenNameChars, r) {
return fmt.Errorf("name %q contains %q", name, r)
}
}
base := name
if i := strings.IndexByte(base, '.'); i >= 0 {
base = base[:i]
}
if reservedNames[strings.ToUpper(base)] {
return fmt.Errorf("name %q is reserved on Windows", name)
}
return nil
}