-
-
Notifications
You must be signed in to change notification settings - Fork 31
feat(storage): implement secure filesystem vault manager with atomic writes #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Chethan-Regala
wants to merge
8
commits into
AOSSIE-Org:main
Choose a base branch
from
Chethan-Regala:feat/fileSystem-storage-layer-vault-manager
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
28e9a77
feat(storage): scaffold filesystem vault module
2b6f004
feat(storage): define filesystem vault type contracts
9737cbb
feat(storage): implement vault path validation and traversal protection
5f2a299
feat(storage): implement atomic file write utility for vault
b4409a1
feat(storage): implement filesystem vault manager
fdcae89
feat(storage): add vault demo runner for testing storage module
1f14e64
feat(storage): expose public API for vault storage module
dd1e912
fix(storage): enforce service contracts and prevent overwrite
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export{} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. "../").` | ||
| ); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
Chethan-Regala marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| function isWindowsEperm(error: unknown): error is NodeJS.ErrnoException { | ||
| return ( | ||
| typeof error === "object" && | ||
| error !== null && | ||
| "code" in error && | ||
| (error as NodeJS.ErrnoException).code === "EPERM" | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.