]> git.wincent.com - wincent.git/blob - src/Fig/compare.ts
477f2fb361f2d340d7b56f225dba8e2212492a26
[wincent.git] / src / Fig / compare.ts
1 // TODO: move a lot of the stuff that is currently under "Fig/" out of it
2 // (original intent was to have a separation between generic stuff and
3 // configuration-framework-specific entities. But in practice, the use of global
4 // state and the amount of coupling we have between different modules means we
5 // may as well consider them all to be equal citizens.
6 import {promises as fs} from 'fs';
7 import {dirname} from 'path';
8
9 import ErrorWithMetadata from '../ErrorWithMetadata';
10 import stat from '../stat';
11
12 // TODO: decide whether the Ansible definition of "force" (which we use below)
13 // is the one that we want to actual stick with.
14
15 /**
16  * Summary of differences between actual and desired state of a file-system
17  * object (ie. a file, directory, or link).
18  *
19  * Notable properties:
20  *
21  * -  error: Set if there is some reason why the comparison could not be
22  *    completed or the desired end-state cannot be produced (for
23  *    example, if we desired an object with a "state" of "file" at
24  *    "a/b/c", but there is already a file at "a/b", that will be an
25  *    error).
26  * -  force: When `true`, user is asking to create a symlink even if the
27  *    target doesn't exist, and even if there is a file already at the
28  *    destination (which would need to be removed for link creation to
29  *    succeed). In the result, set to `true` to signal that a target
30  *    will need to be removed (for example, to replace a link with a
31  *    directory), or force-written (for example, to replace a file with
32  *    a link; ie. `ln -sf`) etc.
33  * -  path: Read-only property, for book-keeping purposes. The only
34  *    non-optional property.
35  * -  state: A required property, but this one has a default ("file").
36  *
37  * In general, if a property is unset, that means no changes are
38  * required with respect to that property.
39  */
40 type Diff = {
41     contents?: string;
42     error?: Error;
43     force?: boolean;
44     group?: string;
45     mode?: Mode;
46     owner?: string;
47     readonly path: string;
48     state?: 'absent' | 'directory' | 'file' | 'link' | 'touch';
49 };
50
51 const {stringify} = JSON;
52
53 /**
54  * Given a desired end-state of a file-system object (ie. a file,
55  * directory, or link), returns a "diff" of the changes that need to be
56  * made to the current file system to produce that desired end-state.
57  */
58 export default async function compare({
59     contents,
60     force,
61     group,
62     mode,
63     owner,
64     path,
65     state = 'file',
66 }: Diff) {
67     // Sanity check.
68     if (
69         contents !== undefined &&
70         (state === 'absent' || state === 'directory' || state === 'touch')
71     ) {
72         throw new ErrorWithMetadata(
73             `A file-system object cannot have "contents" if its state is \`${state}\``
74         );
75     }
76
77     const diff: Diff = {path};
78
79     const stats = await stat(path);
80
81     if (stats instanceof Error) {
82         // Can't stat; bail.
83         diff.error = stats;
84         return diff;
85     } else if (stats === null) {
86         // Object does not exist.
87         if (state === 'absent') {
88             // Want "absent", have "absent": no state change required.
89         } else {
90             // Distinguish between `path` itself not existing (in which case
91             // it can be created), and one of its parents not existing (in which case
92             // we have to bail).
93             const parent = dirname(path);
94             const stats = await stat(parent);
95             if (stats instanceof Error) {
96                 // Unlikely (we were able to stat object but not its parent).
97                 diff.error = stats;
98                 return diff;
99             } else if (stats === null) {
100                 diff.error = new ErrorWithMetadata(
101                     `Cannot stat ${stringify(path)} because parent ${stringify(
102                         parent
103                     )} does not exist`
104                 );
105             } else {
106                 // Parent exists.
107                 diff.state = state;
108             }
109         }
110         // Nothing else we can check without the object existing.
111         return diff;
112     }
113
114     // Object exists.
115     if (state === 'file') {
116         if (stats.type === 'file') {
117             // Want "file", have "file": no state change required.
118         } else if (stats.type === 'link') {
119             // Going to have to overwrite symlink.
120             diff.force = true;
121             diff.state = 'file';
122         } else if (stats.type === 'directory') {
123             diff.error = new ErrorWithMetadata(
124                 `Cannot replace directory ${stringify(path)} with file`
125             );
126         } else {
127             // We're not going to bother with "exotic" types such as sockets etc.
128             diff.error = new ErrorWithMetadata(
129                 `Cannot replace object ${stringify(
130                     path
131                 )} of unknown type with file`
132             );
133         }
134
135         if (typeof contents === 'string') {
136             try {
137                 const actual = await fs.readFile(path, 'utf8');
138                 if (actual !== contents) {
139                     diff.contents = contents;
140                 }
141             } catch (error) {
142                 // TODO: if this is a perms issue, that might be ok as long as user has
143                 // specified "user"
144             }
145         }
146
147         if (group && stats.group !== group) {
148             diff.group = group;
149         }
150
151         if (owner && stats.owner !== owner) {
152             diff.owner = owner;
153         }
154     } else if (state === 'directory') {
155         if (stats.type === 'directory') {
156             // Want "directory", have "directory": no state change required.
157         } else if (stats.type === 'file' || stats.type === 'link') {
158             if (force) {
159                 // Will have to remove file/link before creating directory.
160                 diff.force = true;
161                 diff.state = state;
162             } else {
163                 const entity = stats.type === 'file' ? 'file' : 'symbolic link';
164
165                 diff.error = new ErrorWithMetadata(
166                     `Cannot replace ${entity} ${stringify(
167                         path
168                     )} with directory without 'force'`
169                 );
170             }
171         } else {
172             // We're not going to bother with "exotic" types such as sockets etc.
173             diff.error = new ErrorWithMetadata(
174                 `Cannot replace object ${stringify(
175                     path
176                 )} of unknown type with directory`
177             );
178         }
179     } else if (state === 'link') {
180         // TODO
181     } else if (state === 'absent') {
182         // TODO
183     } else if (state === 'touch') {
184         // TODO
185     }
186
187     return diff;
188 }