Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/storage/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions apps/storage/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "storage",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@types/node": "^25.4.0",
"typescript": "^5.9.3"
}
}
1 change: 1 addition & 0 deletions apps/storage/src/FileSystemUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export{}
132 changes: 132 additions & 0 deletions apps/storage/src/PathValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @file PathValidator.ts
* @description Secure path resolution and validation for the Smart Notes vault.
*
* All filesystem paths supplied by the application layer must be validated
* before being handed to Node's `fs` APIs. Without this guard a maliciously
* or accidentally crafted relative path such as `../../etc/passwd` could
* escape the vault root and read or overwrite arbitrary files on the host.
*
* Every storage operation should call {@link resolveVaultPath} to obtain a
* safe absolute path and then {@link validateVaultPath} to assert containment
* before proceeding with any filesystem I/O.
*/

import path from "path";

// ---------------------------------------------------------------------------
// Path Resolution
// ---------------------------------------------------------------------------

/**
* Resolves a vault-relative path to a normalised absolute filesystem path.
*
* The function joins `rootPath` and `relativePath`, then calls `path.resolve`
* which both normalises redundant separators / dot segments and anchors the
* result to an absolute path regardless of the process working directory.
*
* **This function alone does not guarantee containment.** Always follow up
* with {@link validateVaultPath} to assert that the resolved path is still
* inside the vault root.
*
* @param rootPath - Absolute path to the vault's root directory.
* Must be an absolute path; passing a relative root
* produces unpredictable results.
* @param relativePath - Path supplied by the caller, relative to `rootPath`.
* May contain `.` or `..` segments — these are resolved
* by `path.resolve` and then checked by the validator.
* @returns The normalised, absolute path produced by joining the two inputs.
*
* @example
* ```ts
* resolveVaultPath("/vault", "notes/daily.md");
* // → "/vault/notes/daily.md"
*
* resolveVaultPath("/vault", "./projects/../ideas/x.md");
* // → "/vault/ideas/x.md"
* ```
*/
export function resolveVaultPath(
rootPath: string,
relativePath: string
): string {
// path.resolve joins the segments left-to-right and normalises the result
// into an absolute path, collapsing any `.` and `..` components in one step.
return path.resolve(rootPath, relativePath);
}

// ---------------------------------------------------------------------------
// Containment Validation
// ---------------------------------------------------------------------------

/**
* Asserts that `resolvedPath` is located inside `rootPath`.
*
* ### Security rationale
* A path traversal attack uses sequences such as `../` to walk up the
* directory tree past the intended root. Even after normalisation a path
* like `/vault/../etc/passwd` collapses to `/etc/passwd`, which is outside
* the vault. This function prevents such escapes by verifying that the
* normalised absolute path still begins with the normalised root prefix.
*
* The root prefix check uses a trailing-separator sentinel (`rootWithSep`)
* to avoid false positives where the vault root is `/vault` but the resolved
* path is `/vault-backup/secret` — without the sentinel both strings would
* share the prefix `/vault`.
*
* ### Usage pattern
* ```ts
* const resolved = resolveVaultPath(rootPath, userSuppliedPath);
* validateVaultPath(rootPath, resolved); // throws if outside vault
* await fs.readFile(resolved, "utf8"); // safe to proceed
* ```
*
* @param rootPath - Absolute path to the vault's root directory.
* Normalised internally before comparison.
* @param resolvedPath - The fully resolved absolute path to validate.
* Typically the return value of {@link resolveVaultPath}.
*
* @throws {Error} When `resolvedPath` does not begin with the vault root prefix,
* indicating a path traversal attempt or misconfiguration.
*
* @example
* ```ts
* // Safe — resolvedPath is inside the vault.
* validateVaultPath("/vault", "/vault/notes/daily.md"); // no-op
*
* // Unsafe — resolvedPath escapes the vault root.
* validateVaultPath("/vault", "/etc/passwd");
* // → throws Error: Path traversal detected …
* ```
*/
export function validateVaultPath(
rootPath: string,
resolvedPath: string
): void {
// Normalise both sides independently so that inconsistent trailing slashes
// or mixed separators on Windows do not produce false negatives.
const normalisedRoot = path.normalize(rootPath);
const normalisedResolved = path.normalize(resolvedPath);

// Append the platform separator so that a vault at "/vault" cannot be
// bypassed by a resolved path of "/vault-escape/file.md".
// We also accept an exact match (resolvedPath === root) to allow operations
// directly on the vault root directory itself (e.g. listing).
const rootWithSep = normalisedRoot.endsWith(path.sep)
? normalisedRoot
: normalisedRoot + path.sep;

const isInsideVault =
normalisedResolved === normalisedRoot ||
normalisedResolved.startsWith(rootWithSep);

if (!isInsideVault) {
throw new Error(
`Path traversal detected: resolved path is outside the vault root.\n` +
` Vault root : ${normalisedRoot}\n` +
` Resolved path : ${normalisedResolved}\n` +
`Ensure all paths are relative to the vault root and contain no ` +
`traversal segments (e.g. "../").`
);
}
}
105 changes: 105 additions & 0 deletions apps/storage/src/SafeWrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* @file SafeWrite.ts
* @description Atomic file writing utilities for the Smart Notes vault.
*
* A naive `fs.writeFile` call truncates the target file before writing new
* content. If the process crashes, is killed, or loses power mid-write, the
* file is left empty or partially written, corrupting the user's note.
*
* This module solves that problem with the write-to-temp-then-rename pattern,
* which is the standard technique used by databases, editors, and package
* managers to achieve crash-safe persistence:
*
* 1. Write the full content to a sibling `.tmp` file.
* 2. Best-effort `fsync` the temp file on platforms that support it reliably.
* 3. Atomically `rename` the temp file over the target path.
*
* On POSIX systems `rename(2)` is guaranteed to be atomic by the kernel - the
* target path will always point to either the old file or the new file, never
* to a partially written state. On Windows, `fs.rename` provides a best-effort
* equivalent that is safe for single-writer scenarios like Smart Notes.
*/

import { open, rename, writeFile } from "fs/promises";
import type { FileHandle } from "fs/promises";

// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------

/**
* Writes `content` to `filePath` atomically, preventing partial writes.
*
* ### Why atomic writes matter
* A regular `fs.writeFile` first truncates the destination file then writes
* bytes incrementally. Any interruption between those two steps (crash, SIGKILL,
* power loss) leaves the file empty or corrupted. Because Smart Notes operates
* offline-first with no network backup, a corrupted note cannot be recovered.
*
* ### How this function stays safe
* - Content is written to a temporary file (`<filePath>.tmp`) so the original
* file is untouched until the new content is fully on disk.
* - A best-effort `fsync` flushes the OS write-back cache to durable storage
* before the rename on platforms where this succeeds reliably.
* - `rename` atomically replaces the target path in a single kernel operation.
* At no point is the target path missing or partially updated.
*
* ### Cleanup on failure
* If any step throws, the `.tmp` file may be left on disk. This is intentional
* - a stale `.tmp` file is harmless and will be overwritten on the next write
* attempt. The original file is always preserved.
*
* @param filePath - Absolute path to the destination file.
* The parent directory must already exist.
* @param content - UTF-8 Markdown string to write.
* @returns A promise that resolves once the file has been written and the
* target path has been atomically updated.
*
* @throws {Error} If the temp file cannot be written or renamed. A failed
* best-effort `fsync` is ignored to preserve cross-platform
* compatibility, especially on Windows.
*
* @example
* ```ts
* await writeFileAtomic("/vault/notes/daily.md", "# Today\n\nHello.");
* ```
*/
export async function writeFileAtomic(
filePath: string,
content: string
): Promise<void> {
// Place the temp file beside the destination so rename stays on the same
// filesystem and retains its atomic replace semantics.
const tempPath = `${filePath}.tmp`;

// If the process dies here the original file is still untouched.
await writeFile(tempPath, content, "utf8");

let fileHandle: FileHandle | undefined;

try {
// Open read-only to obtain a descriptor for sync without risking truncation.
fileHandle = await open(tempPath, "r");
await fileHandle.sync();
} catch (error: unknown) {
if (!isWindowsEperm(error)) {
throw error;
}

// Some Windows environments reject fsync on newly written files with EPERM.
// The atomic rename is still the required correctness boundary for desktop use.
} finally {
await fileHandle?.close();
}

await rename(tempPath, filePath);
}

function isWindowsEperm(error: unknown): error is NodeJS.ErrnoException {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "EPERM"
);
}
Loading
Loading