1
1
mirror of https://github.com/mgree/ffs.git synced 2024-07-14 23:00:24 +03:00

--new flag for creating files from empty (#38)

Running `--new FILE.EXT` will:

- infer the output format form `EXT`
- use `FILE.EXT` as the output
- start with a single, empty, named directory (but allocate a bit more space)

In implementing this, I realized that introducing metadata (fad45bed4b) meant we no longer inferred types automatically. I added a type `Typ::Auto` and some inference code.
This commit is contained in:
Michael Greenberg 2021-07-04 18:19:31 -07:00 committed by GitHub
parent 7249ae95df
commit a1d5718ac9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 499 additions and 167 deletions

View File

@ -8,6 +8,7 @@
* `--pretty` flag for JSON and TOML.
* Wrote INSTALL.md.
* Improved manpage.
* `--new` flag for starting from an empty filesystem.
## 0.1.0 - 2021-06-26

View File

@ -8,6 +8,7 @@ ffs - the file filesystem
# SYNOPSIS
| ffs \[*FLAGS*\] \[*OPTIONS*\] \[*INPUT*\]
| ffs \[*FLAGS*\] \[*OPTIONS*\] --new \[*OUTPUT*\]
| ffs *--completions* *SHELL*
| ffs \[*-h*\|*--help*\]
| ffs \[*-V*\|*--version*\]
@ -103,6 +104,14 @@ installed on your system to use *ffs*.
: Sets the output file for saving changes (defaults to stdout)
--new *NEW*
: Mounts an empty filesystem, inferring a mountpoint and output format. Running --new *FILE*.*EXT* is morally equivalent to running:
```
echo '{}' | ffs --source json -o *FILE*.*EXT* --target *EXT* -m *FILE*
```
where the mountpoint *FILE* will be created (and removed) by ffs.
--completions *SHELL*
: Generate shell completions (and exits) [possible values: bash, fish,
@ -138,13 +147,20 @@ formats (currently, JSON, TOML, and YAML); *ffs* maps values in these
formats to filesystems. Here are the different types and how they're
mapped to a filesystem:
auto
: Automatically detected. The following order is used for UTF-8
encodable data: null, boolean, integer, float, datetime, string. If
data can't be encoded in UTF-8, it will always be bytes.
boolean
: Mapped to a **file**. Either *true* or *false*.
bytes
: Mapped to a **file**. When serializing back to format, it will be encoded in base64.
: Mapped to a **file**. When saving, bytes are typically encoded in
base64.
datetime
@ -165,9 +181,9 @@ list
named elements, starting from 0. Filenames will be padded with zeros
to ensure proper sorting; use *--unpadded* to disable padding. While
mounted, you are free to use whatever filenames you like in a list
directory. When list directories are serialized back to a format,
filenames are ignored and the sorted order of the files (in the
current locale) will be used to determine the list order.
directory. When list directories are saved, filenames are ignored
and the sorted order of the files (in the current locale) will be
used to determine the list order.
named
@ -229,6 +245,15 @@ umount commits
# changes are written back to commits.json (-i is in-place mode)
```
If you want to create a new file wholesale, the --new flag is helpful.
```shell
ffs --new file.json
# do edits in file directory
umount file
# corresponding json is in file.json
```
To mount a JSON file and write back out a YAML file, you could run:
```shell

View File

@ -11,6 +11,10 @@ ffs [\f[I]FLAGS\f[R]] [\f[I]OPTIONS\f[R]] [\f[I]INPUT\f[R]]
.PD 0
.P
.PD
ffs [\f[I]FLAGS\f[R]] [\f[I]OPTIONS\f[R]] --new [\f[I]OUTPUT\f[R]]
.PD 0
.P
.PD
ffs \f[I]--completions\f[R] \f[I]SHELL\f[R]
.PD 0
.P
@ -92,8 +96,25 @@ specified when running on stdin
-o, --output \f[I]OUTPUT\f[R]
Sets the output file for saving changes (defaults to stdout)
.TP
--new \f[I]NEW\f[R]
Mounts an empty filesystem, inferring a mountpoint and output format.
Running --new \f[I]FILE\f[R].\f[I]EXT\f[R] is morally equivalent to
running:
.RS
.IP
.nf
\f[C]
echo \[aq]{}\[aq] | ffs --source json -o *FILE*.*EXT* --target *EXT* -m *FILE*
\f[R]
.fi
.PP
where the mountpoint \f[I]FILE\f[R] will be created (and removed) by
ffs.
.RE
.TP
--completions \f[I]SHELL\f[R]
Generate shell completions and exit [possible values: bash, fish, zsh]
Generate shell completions (and exits) [possible values: bash, fish,
zsh]
.TP
-s, --source \f[I]SOURCE_FORMAT\f[R]
Specify the source format explicitly (by default, automatically inferred
@ -117,13 +138,19 @@ formats (currently, JSON, TOML, and YAML); \f[I]ffs\f[R] maps values in
these formats to filesystems.
Here are the different types and how they\[aq]re mapped to a filesystem:
.TP
auto
Automatically detected.
The following order is used for UTF-8 encodable data: null, boolean,
integer, float, datetime, string.
If data can\[aq]t be encoded in UTF-8, it will always be bytes.
.TP
boolean
Mapped to a \f[B]file\f[R].
Either \f[I]true\f[R] or \f[I]false\f[R].
.TP
bytes
Mapped to a \f[B]file\f[R].
When serializing back to format, it will be encoded in base64.
When saving, bytes are typically encoded in base64.
.TP
datetime
Mapped to a \f[B]file\f[R].
@ -145,9 +172,9 @@ Filenames will be padded with zeros to ensure proper sorting; use
\f[I]--unpadded\f[R] to disable padding.
While mounted, you are free to use whatever filenames you like in a list
directory.
When list directories are serialized back to a format, filenames are
ignored and the sorted order of the files (in the current locale) will
be used to determine the list order.
When list directories are saved, filenames are ignored and the sorted
order of the files (in the current locale) will be used to determine the
list order.
.TP
named
Mapped to a \f[B]directory\f[R].
@ -214,6 +241,17 @@ umount commits
\f[R]
.fi
.PP
If you want to create a new file wholesale, the --new flag is helpful.
.IP
.nf
\f[C]
ffs --new file.json
# do edits in file directory
umount file
# corresponding json is in file.json
\f[R]
.fi
.PP
To mount a JSON file and write back out a YAML file, you could run:
.IP
.nf

View File

@ -134,6 +134,15 @@ pub fn app() -> App<'static, 'static> {
.short("m")
.takes_value(true)
)
.arg(
Arg::with_name("NEW")
.help("Mounts an empty filesystem, inferring a mountpoint and output format")
.long("new")
.takes_value(true)
.conflicts_with("INPLACE")
.conflicts_with("SOURCE_FORMAT")
.conflicts_with("OUTPUT")
)
.arg(
Arg::with_name("INPUT")
.help("Sets the input file ('-' means STDIN)")

View File

@ -36,11 +36,13 @@ pub struct Config {
pub enum Input {
Stdin,
File(PathBuf),
Empty,
}
impl std::fmt::Display for Input {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
Input::Empty => write!(f, "<empty>"),
Input::Stdin => write!(f, "<stdin>"),
Input::File(file) => write!(f, "{}", file.display()),
}

View File

@ -6,7 +6,7 @@ use tracing::{debug, error, info, instrument, warn};
use fuser::FileType;
use super::config::{Config, Output};
use super::config::{Config, Input, Output};
use super::fs::{DirEntry, DirType, Entry, Inode, FS};
use ::toml as serde_toml;
@ -24,6 +24,7 @@ pub enum Format {
/// Types classifying string data.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Typ {
Auto,
Null,
Boolean,
Integer,
@ -53,6 +54,7 @@ impl std::fmt::Display for Typ {
f,
"{}",
match self {
Typ::Auto => "auto",
Typ::Null => "null",
Typ::Boolean => "boolean",
Typ::Bytes => "bytes",
@ -95,7 +97,9 @@ impl FromStr for Typ {
fn from_str(s: &str) -> Result<Self, ()> {
let s = s.trim().to_lowercase();
if s == "null" {
if s == "auto" {
Ok(Typ::Auto)
} else if s == "null" {
Ok(Typ::Null)
} else if s == "boolean" || s == "bool" {
Ok(Typ::Boolean)
@ -127,10 +131,37 @@ impl Format {
/// particular `Config`.
///
/// NB there is no check that `self == fs.config.input_format`!
#[instrument(level = "info", skip(reader, config))]
pub fn load(&self, reader: Box<dyn std::io::Read>, config: Config) -> FS {
#[instrument(level = "info", skip(config))]
pub fn load(&self, config: Config) -> FS {
let mut inodes: Vec<Option<Inode>> = Vec::new();
let reader: Box<dyn std::io::Read> = match &config.input {
Input::Stdin => Box::new(std::io::stdin()),
Input::File(file) => {
let fmt = config.input_format;
let file = std::fs::File::open(&file).unwrap_or_else(|e| {
error!("Unable to open {} for {} input: {}", file.display(), fmt, e);
std::process::exit(1);
});
Box::new(file)
}
Input::Empty => {
// let's just reserve some space for later and get cracking
info!("reserving space in empty filesystem");
inodes.resize_with(1024, || None);
// create an empty directory
let contents = HashMap::with_capacity(16);
inodes[1] = Some(Inode::new(
fuser::FUSE_ROOT_ID,
fuser::FUSE_ROOT_ID,
Entry::Directory(DirType::Named, contents),
&config,
));
return FS::new(inodes, config);
}
};
match self {
Format::Json => {
info!("reading json value");
@ -478,6 +509,19 @@ mod json {
fn from_string(typ: Typ, contents: String, _config: &Config) -> Self {
match typ {
Typ::Auto => {
if contents.is_empty() {
Value::Null
} else if contents == "true" {
Value::Bool(true)
} else if contents == "false" {
Value::Bool(false)
} else if let Ok(n) = serde_json::Number::from_str(&contents) {
Value::Number(n)
} else {
Value::String(contents)
}
}
Typ::Boolean => {
if contents == "true" {
Value::Bool(true)
@ -613,6 +657,21 @@ mod toml {
fn from_string(typ: Typ, contents: String, _config: &Config) -> Self {
match typ {
Typ::Auto => {
if contents == "true" {
Value::Boolean(true)
} else if contents == "false" {
Value::Boolean(false)
} else if let Ok(n) = i64::from_str(&contents) {
Value::Integer(n)
} else if let Ok(n) = f64::from_str(&contents) {
Value::Float(n)
} else if let Ok(datetime) = str::parse(&contents) {
Value::Datetime(datetime)
} else {
Value::String(contents)
}
}
Typ::Boolean => {
if contents == "true" {
Value::Boolean(true)
@ -808,6 +867,21 @@ mod yaml {
fn from_string(typ: Typ, contents: String, _config: &Config) -> Self {
match typ {
Typ::Auto => {
if contents.is_empty() {
Value(Yaml::Null)
} else if contents == "true" {
Value(Yaml::Boolean(true))
} else if contents == "false" {
Value(Yaml::Boolean(false))
} else if let Ok(n) = i64::from_str(&contents) {
Value(Yaml::Integer(n))
} else if let Ok(_n) = f64::from_str(&contents) {
Value(Yaml::Real(contents))
} else {
Value(Yaml::String(contents))
}
}
Typ::Boolean => {
if contents == "true" {
Value(Yaml::Boolean(true))

View File

@ -950,7 +950,7 @@ impl Filesystem for FS {
// create the inode entry
let (entry, kind) = if file_type == libc::S_IFREG as u32 {
(Entry::File(Typ::String, Vec::new()), FileType::RegularFile)
(Entry::File(Typ::Auto, Vec::new()), FileType::RegularFile)
} else {
assert_eq!(file_type, libc::S_IFDIR as u32);
(

View File

@ -129,75 +129,85 @@ fn main() {
None => config.gid = unsafe { libc::getegid() },
}
config.input = match args.value_of("INPUT") {
Some(input_source) => {
if input_source == "-" {
Input::Stdin
} else {
let input_source = PathBuf::from(input_source);
if !input_source.exists() {
error!("Input file {} does not exist.", input_source.display());
std::process::exit(1);
}
Input::File(input_source)
}
}
None => Input::Stdin,
};
config.output = if let Some(output) = args.value_of("OUTPUT") {
Output::File(PathBuf::from(output))
} else if args.is_present("INPLACE") {
match &config.input {
Input::Stdin => {
warn!(
"In-place output `-i` with STDIN input makes no sense; outputting on STDOUT."
);
Output::Stdout
}
Input::File(input_source) => Output::File(input_source.clone()),
}
} else if args.is_present("NOOUTPUT") || args.is_present("QUIET") {
Output::Quiet
} else {
Output::Stdout
};
// infer and create mountpoint from filename as possible
config.mount = match args.value_of("MOUNT") {
Some(mount_point) => {
let mount_point = PathBuf::from(mount_point);
if !mount_point.exists() {
error!("Mount point {} does not exist.", mount_point.display());
match args.value_of("NEW") {
Some(target_file) => {
if args.occurrences_of("INPUT") != 0 {
error!("It doesn't make sense to set `--new` with a specified input file.");
std::process::exit(1);
}
config.cleanup_mount = false;
Some(mount_point)
}
None => {
match &config.input {
Input::Stdin => {
error!("You must specify a mount point when reading from stdin.");
std::process::exit(1);
let output = PathBuf::from(target_file);
if output.exists() {
error!("Output file {} already exists.", output.display());
std::process::exit(1);
}
let format = match args
.value_of("TARGET_FORMAT")
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(target_format) => target_format,
Err(e) => {
match e {
format::ParseFormatError::NoSuchFormat(s) => {
warn!(
"Unrecognized format '{}', inferring from {}.",
s,
output.display(),
)
}
format::ParseFormatError::NoFormatProvided => {
debug!("Inferring output format from input.")
}
};
match output
.extension()
.and_then(|s| s.to_str())
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(format) => format,
Err(_) => {
error!(
"Unrecognized format '{}'; use --target or a known extension to specify a format.",
output.display()
);
std::process::exit(1);
}
}
}
Input::File(file) => {
// If the input is from a file foo.EXT, then try to make a directory foo.
let mount_dir = file.with_extension("");
};
let mount = match args.value_of("MOUNT") {
Some(mount_point) => {
let mount_point = PathBuf::from(mount_point);
if !mount_point.exists() {
error!("Mount point {} does not exist.", mount_point.display());
std::process::exit(1);
}
config.cleanup_mount = false;
Some(mount_point)
}
None => {
// If the output is to a file foo.EXT, then try to make a directory foo.
let mount_dir = output.with_extension("");
// If that file already exists, give up and tell the user about --mount.
if mount_dir.exists() {
error!("Inferred mountpoint '{mount}' for input file '{file}', but '{mount}' already exists. Use `--mount MOUNT` to specify a mountpoint.",
mount = mount_dir.display(), file = file.display());
error!("Inferred mountpoint '{mount}' for output file '{file}', but '{mount}' already exists. Use `--mount MOUNT` to specify a mountpoint.",
mount = mount_dir.display(), file = output.display());
std::process::exit(1);
}
// If the mountpoint can't be created, give up and tell the user about --mount.
if let Err(e) = std::fs::create_dir(&mount_dir) {
error!(
"Couldn't create mountpoint '{}': {}. Use `--mount MOUNT` to specify a mountpoint.",
mount_dir.display(),
e
);
error!("Couldn't create mountpoint '{}': {}. Use `--mount MOUNT` to specify a mountpoint.",
mount_dir.display(),
e
);
std::process::exit(1);
}
@ -205,98 +215,192 @@ fn main() {
config.cleanup_mount = true;
Some(mount_dir)
}
}
}
};
assert!(config.mount.is_some());
// try to autodetect the input format.
//
// first see if it's specified and parses okay.
//
// then see if we can pull it out of the extension.
//
// then give up and use json
config.input_format = match args
.value_of("SOURCE_FORMAT")
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(source_format) => source_format,
Err(e) => {
match e {
format::ParseFormatError::NoSuchFormat(s) => {
warn!("Unrecognized format '{}', inferring from input.", s)
}
format::ParseFormatError::NoFormatProvided => {
debug!("Inferring format from input.")
}
};
match &config.input {
Input::Stdin => Format::Json,
Input::File(input_source) => match input_source
.extension()
.and_then(|s| s.to_str())
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(format) => format,
Err(_) => {
warn!(
"Unrecognized format {}, defaulting to JSON.",
input_source.display()
);
Format::Json
config.input = Input::Empty;
config.output = Output::File(output);
config.input_format = format;
config.output_format = format;
config.mount = mount;
}
None => {
// configure input
config.input = match args.value_of("INPUT") {
Some(input_source) => {
if input_source == "-" {
Input::Stdin
} else {
let input_source = PathBuf::from(input_source);
if !input_source.exists() {
error!("Input file {} does not exist.", input_source.display());
std::process::exit(1);
}
Input::File(input_source)
}
},
}
}
};
// try to autodetect the output format.
//
// first see if it's specified and parses okay.
//
// then see if we can pull it out of the extension (if specified)
//
// then give up and use the input format
config.output_format = match args
.value_of("TARGET_FORMAT")
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(target_format) => target_format,
Err(e) => {
match e {
format::ParseFormatError::NoSuchFormat(s) => {
warn!(
"Unrecognized format '{}', inferring from input and output.",
s
)
}
format::ParseFormatError::NoFormatProvided => {
debug!("Inferring output format from input.")
}
None => Input::Stdin,
};
match args
.value_of("OUTPUT")
.and_then(|s| Path::new(s).extension())
.and_then(|s| s.to_str())
// configure output
config.output = if let Some(output) = args.value_of("OUTPUT") {
Output::File(PathBuf::from(output))
} else if args.is_present("INPLACE") {
match &config.input {
Input::Stdin => {
warn!(
"In-place output `-i` with STDIN input makes no sense; outputting on STDOUT."
);
Output::Stdout
}
Input::Empty => {
warn!(
"In-place output `-i` with empty input makes no sense; outputting on STDOUT."
);
Output::Stdout
}
Input::File(input_source) => Output::File(input_source.clone()),
}
} else if args.is_present("NOOUTPUT") || args.is_present("QUIET") {
Output::Quiet
} else {
Output::Stdout
};
// infer and create mountpoint from filename as possible
config.mount = match args.value_of("MOUNT") {
Some(mount_point) => {
let mount_point = PathBuf::from(mount_point);
if !mount_point.exists() {
error!("Mount point {} does not exist.", mount_point.display());
std::process::exit(1);
}
config.cleanup_mount = false;
Some(mount_point)
}
None => {
match &config.input {
Input::Stdin => {
error!("You must specify a mount point when reading from stdin.");
std::process::exit(1);
}
Input::Empty => {
error!("You must specify a mount point when reading an empty file.");
std::process::exit(1);
}
Input::File(file) => {
// If the input is from a file foo.EXT, then try to make a directory foo.
let mount_dir = file.with_extension("");
// If that file already exists, give up and tell the user about --mount.
if mount_dir.exists() {
error!("Inferred mountpoint '{mount}' for input file '{file}', but '{mount}' already exists. Use `--mount MOUNT` to specify a mountpoint.",
mount = mount_dir.display(), file = file.display());
std::process::exit(1);
}
// If the mountpoint can't be created, give up and tell the user about --mount.
if let Err(e) = std::fs::create_dir(&mount_dir) {
error!(
"Couldn't create mountpoint '{}': {}. Use `--mount MOUNT` to specify a mountpoint.",
mount_dir.display(),
e
);
std::process::exit(1);
}
// We did it!
config.cleanup_mount = true;
Some(mount_dir)
}
}
}
};
assert!(config.mount.is_some());
// try to autodetect the input format.
//
// first see if it's specified and parses okay.
//
// then see if we can pull it out of the extension.
//
// then give up and use json
config.input_format = match args
.value_of("SOURCE_FORMAT")
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Some(s) => match s.parse::<Format>() {
Ok(format) => format,
Err(_) => {
warn!(
"Unrecognized format {}, defaulting to input format '{}'.",
s, config.input_format
);
config.input_format
Ok(source_format) => source_format,
Err(e) => {
match e {
format::ParseFormatError::NoSuchFormat(s) => {
warn!("Unrecognized format '{}', inferring from input.", s)
}
format::ParseFormatError::NoFormatProvided => {
debug!("Inferring format from input.")
}
};
match &config.input {
Input::Stdin => Format::Json,
Input::Empty => Format::Json,
Input::File(input_source) => match input_source
.extension()
.and_then(|s| s.to_str())
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(format) => format,
Err(_) => {
warn!(
"Unrecognized format {}, defaulting to JSON.",
input_source.display()
);
Format::Json
}
},
}
},
None => config.input_format,
}
}
};
// try to autodetect the output format.
//
// first see if it's specified and parses okay.
//
// then see if we can pull it out of the extension (if specified)
//
// then give up and use the input format
config.output_format = match args
.value_of("TARGET_FORMAT")
.ok_or(format::ParseFormatError::NoFormatProvided)
.and_then(|s| s.parse::<Format>())
{
Ok(target_format) => target_format,
Err(e) => {
match e {
format::ParseFormatError::NoSuchFormat(s) => {
warn!(
"Unrecognized format '{}', inferring from input and output.",
s
)
}
format::ParseFormatError::NoFormatProvided => {
debug!("Inferring output format from input.")
}
};
match args
.value_of("OUTPUT")
.and_then(|s| Path::new(s).extension())
.and_then(|s| s.to_str())
{
Some(s) => match s.parse::<Format>() {
Ok(format) => format,
Err(_) => {
warn!(
"Unrecognized format {}, defaulting to input format '{}'.",
s, config.input_format
);
config.input_format
}
},
None => config.input_format,
}
}
};
}
};
@ -331,18 +435,7 @@ fn main() {
};
let cleanup_mount = config.cleanup_mount;
let input_format = config.input_format;
let reader: Box<dyn std::io::Read> = match &config.input {
Input::Stdin => Box::new(std::io::stdin()),
Input::File(file) => {
let fmt = config.input_format;
let file = std::fs::File::open(&file).unwrap_or_else(|e| {
error!("Unable to open {} for {} input: {}", file.display(), fmt, e);
std::process::exit(1);
});
Box::new(file)
}
};
let fs = input_format.load(reader, config);
let fs = input_format.load(config);
info!("mounting on {:?} with options {:?}", mount, options);
fuser::mount2(fs, &mount, &options).unwrap();

47
tests/auto.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/sh
fail() {
echo FAILED: $1
if [ "$MNT" ]
then
umount "$MNT"
rm "$FILE" "$EXP"
rmdir "$MNT"
fi
exit 1
}
MNT=$(mktemp -d)
FILE=$(mktemp).json
echo '{}' >"$FILE"
EXP=$(mktemp)
printf '{"favorite_number":47,"likes":{"cats":false,"dogs":true},"mistakes":null,"name":"Michael Greenberg","website":"https://mgree.github.io"}' >"$EXP"
ffs -m "$MNT" -i "$FILE" &
PID=$!
sleep 2
ls "$MNT"
[ $(ls $MNT) ] && fail nonempty1
[ $(ls $MNT | wc -l) -eq 0 ] || fail nonempty2
echo 47 >"$MNT"/favorite_number
mkdir "$MNT"/likes
echo true >"$MNT"/likes/dogs
echo false >"$MNT"/likes/cats
touch "$MNT"/mistakes
echo Michael Greenberg >"$MNT"/name
echo https://mgree.github.io >"$MNT"/website
umount "$MNT" || fail unmount
sleep 1
kill -0 $PID >/dev/null 2>&1 && fail process
cat "$FILE"
diff "$FILE" "$EXP" || fail diff
rm "$FILE" "$EXP"
rmdir "$MNT"

43
tests/new.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/sh
fail() {
echo FAILED: $1
if [ "$MNT" ]
then
umount "$MNT"
rmdir "$MNT"
rm "$OUT" "$EXP"
fi
exit 1
}
# really, just for the name
OUT=$(mktemp)
rm "$OUT"
MNT="$OUT"
OUT="$OUT".json
EXP=$(mktemp)
printf '{"handles":{"github":"mgree","stevens":"mgreenbe","twitter":"mgrnbrg"},"problems":99}' >"$EXP"
ffs --new "$OUT" &
PID=$!
sleep 2
[ "$(ls $MNT)" ] && fail nonempty
mkdir "$MNT"/handles
echo mgree >"$MNT"/handles/github
echo mgreenbe >"$MNT"/handles/stevens
echo mgrnbrg >"$MNT"/handles/twitter
echo 99 >"$MNT"/problems
umount "$MNT" || fail unmount
sleep 1
kill -0 $PID >/dev/null 2>&1 && fail process
diff "$OUT" "$EXP" || fail diff
[ -e "$MNT" ] && fail mount
rm "$OUT" "$EXP"