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
2 changes: 1 addition & 1 deletion api/cli/src/fs/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import { PAGE } from "../common.js";
export default {
args: ["ref", "path?"],
options: PAGE,
summary: "a folder's entries; path / by default",
summary: "a folder's entries with mode, mtime and nlink; path / by default",
run: (lf, a, o) => lf.fs.list(a.ref, a.path, o.pageSize, o.pagePos),
};
2 changes: 1 addition & 1 deletion api/cli/src/fs/stat.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
export default {
args: ["ref", "path"],
options: {},
summary: "one entry's type, size and mode",
summary: "one entry's type, size, mode, mtime and nlink",
run: (lf, a) => lf.fs.stat(a.ref, a.path),
};
10 changes: 9 additions & 1 deletion api/docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,15 @@ one: a new file gets `mode`, else 0o644; an existing file keeps its mode unless
`mode` is given; a new folder gets `mode`, else 0o755, while parents it creates
get 0o755 and an existing folder keeps its mode, as `mkdir -p -m` does.
`{ op: "chmod", path, mode }` sets the mode of an existing file or folder.
`fs.stat` reports `mode`. A mode that is not an integer from 0 to 0o7777 is
`fs.stat` reports `mode`, `mtimeSeconds`, `mtimeNanoseconds` and `nlink`.
`mtimeSeconds` and `mtimeNanoseconds` are the portable modification time
(Unix epoch seconds and the fractional second); they are two integers so a
JavaScript Number keeps them exactly. `nlink` is the committed namespace
link count: 0 for the root, 1 for any other directory or symlink, and the
number of names for a regular file (hard-linked names share `inode`).
`fs.list` entries report the same `mode`, `mtimeSeconds`, `mtimeNanoseconds`
and `nlink` beside `name`, `path`, `type` and `inode`, so a directory listing
does not need one `fs.stat` per name. A mode that is not an integer from 0 to 0o7777 is
`InvalidInput`; one LayerFS cannot store (set-user-ID or set-group-ID on any
entry, the sticky bit on a file) is `InvalidInput` with `detail`
`{ reason: "Mode", path, mode }` (octal text), as in `project.init`. A symbolic
Expand Down
3 changes: 2 additions & 1 deletion engine/crates/layerfs-api/src/fs/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use crate::base::args::{optional_path, paging, snapshot, Answer};
use layerfs_bridge::control::SdkValue;
use layerfs_store_service::{read::reads, StoreService};

/// `list(path?, { branch, commit?, pageSize?, pagePos? })`: one window of a directory.
/// `list(path?, { branch, commit?, pageSize?, pagePos? })`: one window of a
/// directory; each entry has type, inode, mode, mtime and nlink.
pub fn list(service: &StoreService, args: &SdkValue) -> Answer {
let (reader, snap) = snapshot(service, args)?;
let path = optional_path(args)?;
Expand Down
2 changes: 1 addition & 1 deletion engine/crates/layerfs-api/src/fs/stat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::base::args::{path, snapshot, Answer};
use layerfs_bridge::control::SdkValue;
use layerfs_store_service::{read::reads, StoreService};

/// `stat(path, { branch, commit? })`: type, inode, size and mode of one path.
/// `stat(path, { branch, commit? })`: type, inode, size, mode, mtime and nlink of one path.
pub fn stat(service: &StoreService, args: &SdkValue) -> Answer {
let (reader, snap) = snapshot(service, args)?;
reads::stat(&reader, &snap, &path(args, "path")?)
Expand Down
156 changes: 156 additions & 0 deletions engine/crates/layerfs-api/tests/fs_stat_metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Modification time and committed link count on the Branch file reads
//! (#486). `fs.stat` reports `mtimeSeconds`, `mtimeNanoseconds` and `nlink`
//! beside `mode`. Each `fs.list` entry reports the same four fields, so a
//! listing does not need one `fs.stat` per name. Hard-linked names share
//! `inode` and carry the namespace name count as `nlink`. Seconds and
//! nanoseconds stay separate so a JavaScript Number keeps them exactly.
#[path = "support/installed_store.rs"]
#[allow(dead_code)]
mod installed;
#[path = "support/sdk.rs"]
#[allow(dead_code)]
mod support;

use layerfs_bridge::control::SdkValue;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use support::{items, Change, Fixture};

const FILE_SECONDS: i64 = 1_700_000_123;
const FILE_NANOS: u32 = 123_456_789;

fn t(text: &str) -> SdkValue {
SdkValue::text(text)
}

fn stamp(path: &Path, seconds: i64, nanos: u32) {
let times =
fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::new(seconds as u64, nanos));
fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_times(times)
.unwrap();
}

fn source(root: &Path) -> usize {
fs::write(root.join("a.txt"), b"a\n").unwrap();
fs::set_permissions(root.join("a.txt"), fs::Permissions::from_mode(0o640)).unwrap();
stamp(&root.join("a.txt"), FILE_SECONDS, FILE_NANOS);
fs::hard_link(root.join("a.txt"), root.join("alias.txt")).unwrap();
fs::create_dir(root.join("private")).unwrap();
fs::set_permissions(root.join("private"), fs::Permissions::from_mode(0o750)).unwrap();
fs::write(root.join("private/b.txt"), b"b\n").unwrap();
std::os::unix::fs::symlink("a.txt", root.join("link")).unwrap();
5
}

fn int(value: &SdkValue, name: &str) -> i64 {
value
.get_int(name)
.unwrap_or_else(|| panic!("{name} missing on {value:?}"))
}

fn stat(f: &Fixture, branch: &str, path: &str) -> SdkValue {
f.call("fs.stat", vec![("branch", t(branch)), ("path", t(path))])
}

fn listed(f: &Fixture, branch: &str, path: &str) -> Vec<SdkValue> {
items(&f.call("fs.list", vec![("branch", t(branch)), ("path", t(path))])).to_vec()
}

fn named<'a>(entries: &'a [SdkValue], name: &str) -> &'a SdkValue {
entries
.iter()
.find(|entry| entry.get_text("name") == Some(name))
.unwrap_or_else(|| panic!("no list entry {name}"))
}

/// Init keeps host mode, mtime and hard links; list entries match stat.
#[test]
fn stat_and_list_report_mtime_mode_and_nlink() {
let f = Fixture::new("fs-stat-metadata", source);

let file = stat(&f, "main", "/a.txt");
let alias = stat(&f, "main", "/alias.txt");
assert_eq!(file.get_text("type"), Some("file"));
assert_eq!(int(&file, "mode"), 0o640);
assert_eq!(int(&file, "mtimeSeconds"), FILE_SECONDS);
assert_eq!(int(&file, "mtimeNanoseconds"), i64::from(FILE_NANOS));
assert_eq!(int(&file, "nlink"), 2);
assert_eq!(int(&alias, "inode"), int(&file, "inode"));
assert_eq!(int(&alias, "nlink"), 2);
assert_eq!(int(&alias, "mode"), 0o640);
assert_eq!(int(&alias, "mtimeSeconds"), FILE_SECONDS);

let dir = stat(&f, "main", "/private");
assert_eq!(dir.get_text("type"), Some("dir"));
assert_eq!(int(&dir, "mode"), 0o750);
assert_eq!(int(&dir, "nlink"), 1);

let root = stat(&f, "main", "/");
assert_eq!(root.get_text("type"), Some("dir"));
assert_eq!(int(&root, "nlink"), 0);

let link = stat(&f, "main", "/link");
assert_eq!(link.get_text("type"), Some("symlink"));
assert_eq!(int(&link, "mode"), 0o777);
assert_eq!(int(&link, "nlink"), 1);

let top = listed(&f, "main", "/");
assert_eq!(
top.iter()
.map(|entry| entry.get_text("name").unwrap())
.collect::<Vec<_>>(),
["a.txt", "alias.txt", "link", "private"]
);
for name in ["a.txt", "alias.txt", "link", "private"] {
let path = if name == "private" {
"/private".to_owned()
} else {
format!("/{name}")
};
let from_stat = stat(&f, "main", &path);
let from_list = named(&top, name);
for field in [
"type",
"inode",
"mode",
"mtimeSeconds",
"mtimeNanoseconds",
"nlink",
] {
assert_eq!(from_list.get(field), from_stat.get(field), "{name} {field}");
}
assert_eq!(from_list.get_text("path"), Some(path.as_str()));
}
assert_eq!(
int(named(&top, "a.txt"), "inode"),
int(named(&top, "alias.txt"), "inode")
);

f.fork("feat");
// A content write records the mutation clock (123 s in this fixture).
f.workspace_commit(
"feat",
&[Change::Write("/private/b.txt", b"bb\n")],
41,
|| {},
);
let touched = stat(&f, "feat", "/private/b.txt");
assert_eq!(int(&touched, "mtimeSeconds"), 123);
assert_eq!(int(&touched, "mtimeNanoseconds"), 0);
assert_eq!(int(&touched, "nlink"), 1);
let inner = listed(&f, "feat", "/private");
assert_eq!(int(named(&inner, "b.txt"), "mtimeSeconds"), 123);
assert_eq!(int(named(&inner, "b.txt"), "mtimeNanoseconds"), 0);
assert_ne!(
int(&stat(&f, "main", "/private/b.txt"), "mtimeSeconds"),
123
);

f.cleanup();
}
10 changes: 5 additions & 5 deletions engine/crates/layerfs-api/tests/golden/sdk_methods.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ fs.read -> refused NoSuchPath "/nope does not exist at feat@<hex>" {path: "/nope
fs.read -> refused NoSuchBranch "Branch ghost does not exist" null
fs.read -> refused InvalidInput "missing text argument `path`" null
fs.read -> refused Binary "text contains NUL or invalid UTF-8" null
fs.list -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/", items: [{name: "a.txt", path: "/a.txt", type: "file", inode: 2}, {name: "applied-dir", path: "/applied-dir", type: "dir", inode: 201}, {name: "applied.txt", path: "/applied.txt", type: "file", inode: 200}, {name: "blob.bin", path: "/blob.bin", type: "file", inode: 3}, {name: "dir", path: "/dir", type: "dir", inode: 4}, {name: "made", path: "/made", type: "dir", inode: 72}, {name: "moved.txt", path: "/moved.txt", type: "file", inode: 137}, {name: "new.txt", path: "/new.txt", type: "file", inode: 8}], hasMore: false, nextPagePos: null}
fs.list -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/dir", items: [{name: "b.txt", path: "/dir/b.txt", type: "file", inode: 5}], hasMore: true, nextPagePos: 1}
fs.list -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/", items: [{name: "a.txt", path: "/a.txt", type: "file", inode: 2, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "applied-dir", path: "/applied-dir", type: "dir", inode: 201, mode: 493, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "applied.txt", path: "/applied.txt", type: "file", inode: 200, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "blob.bin", path: "/blob.bin", type: "file", inode: 3, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "dir", path: "/dir", type: "dir", inode: 4, mode: 493, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "made", path: "/made", type: "dir", inode: 72, mode: 493, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "moved.txt", path: "/moved.txt", type: "file", inode: 137, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "new.txt", path: "/new.txt", type: "file", inode: 8, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}], hasMore: false, nextPagePos: null}
fs.list -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/dir", items: [{name: "b.txt", path: "/dir/b.txt", type: "file", inode: 5, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}], hasMore: true, nextPagePos: 1}
fs.list -> refused NotDirectory "expected a directory" null
fs.stat -> ok {kind: "ok", snapshot: "main@<hex>", root: "<hex>", path: "/a.txt", type: "file", inode: 2, size: 14, mode: 420}
fs.stat -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/dir", type: "dir", inode: 4, size: 0, mode: 493}
fs.stat -> ok {kind: "ok", snapshot: "main@<hex>", root: "<hex>", path: "/a.txt", type: "file", inode: 2, size: 14, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}
fs.stat -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/dir", type: "dir", inode: 4, size: 0, mode: 493, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}
fs.grep -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/", items: [], skipped: 1, hasMore: false, nextPagePos: null}
fs.grep -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/dir", items: [{path: "/dir/b.txt", line: 1, text: "bee\n", lineTruncated: false}], skipped: 0, hasMore: false, nextPagePos: null}
fs.grep -> ok {kind: "ok", snapshot: "feat@<hex>", root: "<hex>", path: "/", items: [{path: "/dir/sub/c.rs", line: 1, text: "fn main() {}\n", lineTruncated: false}], skipped: 1, hasMore: false, nextPagePos: null}
Expand All @@ -65,7 +65,7 @@ fs.write -> ok {kind: "Committed", branch: "left", head: "<hex>", commit: {id: "
branch.fork -> ok {kind: "Forked", branch: "right", base: "<hex>", head: null}
fs.write -> ok {kind: "Committed", branch: "right", head: "<hex>", commit: {id: "<hex>", branch: "right", parent: null, secondParent: null, base: "<hex>", root: "<hex>", via: "fs", message: "Apply filesystem changes", mergedFrom: null, time: <varies>, logical: 42, newCanonical: 958, newStored: <varies>, newMetadata: <varies>, displaced: null, lww: [], lwwTruncated: false}}
branch.merge -> ok {kind: "Merged", fastForward: false, branch: "both", head: "<hex>", root: "<hex>", sources: [{ref: "left", node: "<hex>"}, {ref: "right", node: "<hex>"}], mergeBase: "<hex>", conflicts: [], lww: [], conflictsTruncated: false, lwwTruncated: false, units: [], unitsTruncated: false}
fs.list -> ok {kind: "ok", snapshot: "both@<hex>", root: "<hex>", path: "/", items: [{name: "a.txt", path: "/a.txt", type: "file", inode: 2}, {name: "blob.bin", path: "/blob.bin", type: "file", inode: 3}, {name: "dir", path: "/dir", type: "dir", inode: 4}, {name: "left.txt", path: "/left.txt", type: "file", inode: 328}, {name: "right.txt", path: "/right.txt", type: "file", inode: 392}], hasMore: false, nextPagePos: null}
fs.list -> ok {kind: "ok", snapshot: "both@<hex>", root: "<hex>", path: "/", items: [{name: "a.txt", path: "/a.txt", type: "file", inode: 2, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "blob.bin", path: "/blob.bin", type: "file", inode: 3, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "dir", path: "/dir", type: "dir", inode: 4, mode: 493, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "left.txt", path: "/left.txt", type: "file", inode: 328, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}, {name: "right.txt", path: "/right.txt", type: "file", inode: 392, mode: 420, mtimeSeconds: <varies>, mtimeNanoseconds: <varies>, nlink: 1}], hasMore: false, nextPagePos: null}
branch.merge -> refused NameTaken "Branch both already exists" null
branch.merge -> refused InvalidInput "merge takes two different sources" null
branch.merge -> refused ReservedName "\"main\" names the project's main line" null
Expand Down
5 changes: 4 additions & 1 deletion engine/crates/layerfs-api/tests/sdk_method_golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ fn render(value: &SdkValue, out: &mut String) {
out.push_str(name);
out.push_str(": ");
if matches!(item, SdkValue::Int(_))
&& matches!(name.as_str(), "time" | "newStored" | "newMetadata")
&& matches!(
name.as_str(),
"time" | "newStored" | "newMetadata" | "mtimeSeconds" | "mtimeNanoseconds"
)
{
out.push_str("<varies>");
} else {
Expand Down
38 changes: 32 additions & 6 deletions engine/crates/layerfs-store-service/src/read/reads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use layerfs_bridge::control::{SdkRefusal, SdkValue};
use layerfs_content::{
compare::{memo::Memo, EntryFacts},
filesystem::{
attributes::{read_portable, AttributeReadWork},
attributes::{read_portable, AttributeReadWork, PortableMetadata},
directory::read::{list_after, DirectoryReadWork},
sorted::DirectoryRoot,
},
Expand Down Expand Up @@ -76,8 +76,23 @@ pub fn read(
answer(fields)
}

/// The type, inode, size and permission mode of the entry at `path`. An
/// inline mode costs no object read.
/// Seconds and nanoseconds of a portable mtime, plus the committed namespace
/// link count. Seconds and nanoseconds stay separate: a single nanosecond
/// timestamp is not a JavaScript safe integer after 1970.
fn metadata_fields(metadata: PortableMetadata, nlink: u64) -> [(&'static str, SdkValue); 4] {
[
("mode", SdkValue::Int(i64::from(metadata.mode))),
("mtimeSeconds", SdkValue::Int(metadata.mtime_seconds)),
(
"mtimeNanoseconds",
SdkValue::Int(i64::from(metadata.mtime_nanoseconds)),
),
("nlink", SdkValue::Int(nlink as i64)),
]
}

/// The type, inode, size, permission mode, mtime and committed link count of
/// the entry at `path`. Inline portable metadata costs no object read.
pub fn stat(reader: &StorePorts, snap: &Snap, path: &str) -> Answer {
let cached = Cached::new(reader)?;
let entry = existing(&Memo::new(&cached), snap, path)?;
Expand All @@ -93,8 +108,8 @@ pub fn stat(reader: &StorePorts, snap: &Snap, path: &str) -> Answer {
("type", SdkValue::text(kind_name(entry.value.kind))),
("inode", SdkValue::Int(entry.serial as i64)),
("size", SdkValue::Int(size(reader, &entry)? as i64)),
("mode", SdkValue::Int(i64::from(metadata.mode))),
]);
fields.extend(metadata_fields(metadata, entry.value.namespace_ref_count));
answer(fields)
}

Expand Down Expand Up @@ -133,19 +148,26 @@ pub fn list(reader: &StorePorts, snap: &Snap, path: &str, paging: Arg<(usize, u6
continue;
}
if items.len() + taken.len() == limit
|| spent + name.as_str().len() + path.len() + 160 > REPLY_BUDGET
|| spent + name.as_str().len() + path.len() + 240 > REPLY_BUDGET
{
more = true;
break;
}
spent += name.as_str().len() + path.len() + 160;
spent += name.as_str().len() + path.len() + 240;
taken.push((name, *serial));
}
// The page's entries are looked up together, one descent per level.
let serials: Vec<u64> = taken.iter().map(|(_, serial)| *serial).collect();
let values = snap.tree.values(&memo, &serials).map_err(args::content)?;
let mut work = AttributeReadWork::default();
for ((name, serial), value) in taken.into_iter().zip(values) {
let value = value.ok_or_else(|| args::failed("missing bound inode"))?;
// Inline portable metadata is served from the inode value; a
// root-form tree shares this call's memo with the lookups.
let metadata = read_portable(&memo, value.metadata_root, value.kind, &mut work)
.map_err(args::content)?;
let [mode, mtime_seconds, mtime_nanoseconds, nlink] =
metadata_fields(metadata, value.namespace_ref_count);
items.push(SdkValue::map([
("name", SdkValue::text(name.as_str())),
(
Expand All @@ -154,6 +176,10 @@ pub fn list(reader: &StorePorts, snap: &Snap, path: &str, paging: Arg<(usize, u6
),
("type", SdkValue::text(kind_name(value.kind))),
("inode", SdkValue::Int(serial as i64)),
mode,
mtime_seconds,
mtime_nanoseconds,
nlink,
]));
}
if more {
Expand Down