1 /*
2  * Copyright (C) 2012 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.internal.util;
18 
19 import android.annotation.NonNull;
20 import android.os.FileUtils;
21 import android.util.Log;
22 
23 import java.io.BufferedInputStream;
24 import java.io.BufferedOutputStream;
25 import java.io.File;
26 import java.io.FileInputStream;
27 import java.io.FileOutputStream;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.io.OutputStream;
31 import java.util.Objects;
32 import java.util.zip.ZipEntry;
33 import java.util.zip.ZipOutputStream;
34 
35 import libcore.io.IoUtils;
36 
37 /**
38  * Utility that rotates files over time, similar to {@code logrotate}. There is
39  * a single "active" file, which is periodically rotated into historical files,
40  * and eventually deleted entirely. Files are stored under a specific directory
41  * with a well-known prefix.
42  * <p>
43  * Instead of manipulating files directly, users implement interfaces that
44  * perform operations on {@link InputStream} and {@link OutputStream}. This
45  * enables atomic rewriting of file contents in
46  * {@link #rewriteActive(Rewriter, long)}.
47  * <p>
48  * Users must periodically call {@link #maybeRotate(long)} to perform actual
49  * rotation. Not inherently thread safe.
50  *
51  * @hide
52  */
53 public class FileRotator {
54     private static final String TAG = "FileRotator";
55     private static final boolean LOGD = false;
56 
57     private final File mBasePath;
58     private final String mPrefix;
59     private final long mRotateAgeMillis;
60     private final long mDeleteAgeMillis;
61 
62     private static final String SUFFIX_BACKUP = ".backup";
63     private static final String SUFFIX_NO_BACKUP = ".no_backup";
64 
65     // TODO: provide method to append to active file
66 
67     /**
68      * External class that reads data from a given {@link InputStream}. May be
69      * called multiple times when reading rotated data.
70      */
71     public interface Reader {
read(InputStream in)72         public void read(InputStream in) throws IOException;
73     }
74 
75     /**
76      * External class that writes data to a given {@link OutputStream}.
77      */
78     public interface Writer {
write(OutputStream out)79         public void write(OutputStream out) throws IOException;
80     }
81 
82     /**
83      * External class that reads existing data from given {@link InputStream},
84      * then writes any modified data to {@link OutputStream}.
85      */
86     public interface Rewriter extends Reader, Writer {
reset()87         public void reset();
shouldWrite()88         public boolean shouldWrite();
89     }
90 
91     /**
92      * Create a file rotator.
93      *
94      * @param basePath Directory under which all files will be placed.
95      * @param prefix Filename prefix used to identify this rotator.
96      * @param rotateAgeMillis Age in milliseconds beyond which an active file
97      *            may be rotated into a historical file.
98      * @param deleteAgeMillis Age in milliseconds beyond which a rotated file
99      *            may be deleted.
100      */
FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis)101     public FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis) {
102         mBasePath = Objects.requireNonNull(basePath);
103         mPrefix = Objects.requireNonNull(prefix);
104         mRotateAgeMillis = rotateAgeMillis;
105         mDeleteAgeMillis = deleteAgeMillis;
106 
107         // ensure that base path exists
108         mBasePath.mkdirs();
109 
110         // recover any backup files
111         for (String name : mBasePath.list()) {
112             if (!name.startsWith(mPrefix)) continue;
113 
114             if (name.endsWith(SUFFIX_BACKUP)) {
115                 if (LOGD) Log.d(TAG, "recovering " + name);
116 
117                 final File backupFile = new File(mBasePath, name);
118                 final File file = new File(
119                         mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length()));
120 
121                 // write failed with backup; recover last file
122                 backupFile.renameTo(file);
123 
124             } else if (name.endsWith(SUFFIX_NO_BACKUP)) {
125                 if (LOGD) Log.d(TAG, "recovering " + name);
126 
127                 final File noBackupFile = new File(mBasePath, name);
128                 final File file = new File(
129                         mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length()));
130 
131                 // write failed without backup; delete both
132                 noBackupFile.delete();
133                 file.delete();
134             }
135         }
136     }
137 
138     /**
139      * Delete all files managed by this rotator.
140      */
deleteAll()141     public void deleteAll() {
142         final FileInfo info = new FileInfo(mPrefix);
143         for (String name : mBasePath.list()) {
144             if (info.parse(name)) {
145                 // delete each file that matches parser
146                 new File(mBasePath, name).delete();
147             }
148         }
149     }
150 
151     /**
152      * Dump all files managed by this rotator for debugging purposes.
153      */
dumpAll(OutputStream os)154     public void dumpAll(OutputStream os) throws IOException {
155         final ZipOutputStream zos = new ZipOutputStream(os);
156         try {
157             final FileInfo info = new FileInfo(mPrefix);
158             for (String name : mBasePath.list()) {
159                 if (info.parse(name)) {
160                     final ZipEntry entry = new ZipEntry(name);
161                     zos.putNextEntry(entry);
162 
163                     final File file = new File(mBasePath, name);
164                     final FileInputStream is = new FileInputStream(file);
165                     try {
166                         FileUtils.copy(is, zos);
167                     } finally {
168                         IoUtils.closeQuietly(is);
169                     }
170 
171                     zos.closeEntry();
172                 }
173             }
174         } finally {
175             IoUtils.closeQuietly(zos);
176         }
177     }
178 
179     /**
180      * Process currently active file, first reading any existing data, then
181      * writing modified data. Maintains a backup during write, which is restored
182      * if the write fails.
183      */
rewriteActive(Rewriter rewriter, long currentTimeMillis)184     public void rewriteActive(Rewriter rewriter, long currentTimeMillis)
185             throws IOException {
186         final String activeName = getActiveName(currentTimeMillis);
187         rewriteSingle(rewriter, activeName);
188     }
189 
190     @Deprecated
combineActive(final Reader reader, final Writer writer, long currentTimeMillis)191     public void combineActive(final Reader reader, final Writer writer, long currentTimeMillis)
192             throws IOException {
193         rewriteActive(new Rewriter() {
194             @Override
195             public void reset() {
196                 // ignored
197             }
198 
199             @Override
200             public void read(InputStream in) throws IOException {
201                 reader.read(in);
202             }
203 
204             @Override
205             public boolean shouldWrite() {
206                 return true;
207             }
208 
209             @Override
210             public void write(OutputStream out) throws IOException {
211                 writer.write(out);
212             }
213         }, currentTimeMillis);
214     }
215 
216     /**
217      * Process all files managed by this rotator, usually to rewrite historical
218      * data. Each file is processed atomically.
219      */
rewriteAll(Rewriter rewriter)220     public void rewriteAll(Rewriter rewriter) throws IOException {
221         final FileInfo info = new FileInfo(mPrefix);
222         for (String name : mBasePath.list()) {
223             if (!info.parse(name)) continue;
224 
225             // process each file that matches parser
226             rewriteSingle(rewriter, name);
227         }
228     }
229 
230     /**
231      * Process a single file atomically, first reading any existing data, then
232      * writing modified data. Maintains a backup during write, which is restored
233      * if the write fails.
234      */
rewriteSingle(Rewriter rewriter, String name)235     private void rewriteSingle(Rewriter rewriter, String name) throws IOException {
236         if (LOGD) Log.d(TAG, "rewriting " + name);
237 
238         final File file = new File(mBasePath, name);
239         final File backupFile;
240 
241         rewriter.reset();
242 
243         if (file.exists()) {
244             // read existing data
245             readFile(file, rewriter);
246 
247             // skip when rewriter has nothing to write
248             if (!rewriter.shouldWrite()) return;
249 
250             // backup existing data during write
251             backupFile = new File(mBasePath, name + SUFFIX_BACKUP);
252             file.renameTo(backupFile);
253 
254             try {
255                 writeFile(file, rewriter);
256 
257                 // write success, delete backup
258                 backupFile.delete();
259             } catch (Throwable t) {
260                 // write failed, delete file and restore backup
261                 file.delete();
262                 backupFile.renameTo(file);
263                 throw rethrowAsIoException(t);
264             }
265 
266         } else {
267             // create empty backup during write
268             backupFile = new File(mBasePath, name + SUFFIX_NO_BACKUP);
269             backupFile.createNewFile();
270 
271             try {
272                 writeFile(file, rewriter);
273 
274                 // write success, delete empty backup
275                 backupFile.delete();
276             } catch (Throwable t) {
277                 // write failed, delete file and empty backup
278                 file.delete();
279                 backupFile.delete();
280                 throw rethrowAsIoException(t);
281             }
282         }
283     }
284 
285     /**
286      * Process a single file atomically, with the given start and end timestamps.
287      * If a file with these exact start and end timestamps does not exist, a new
288      * empty file will be written.
289      */
rewriteSingle(@onNull Rewriter rewriter, long startTimeMillis, long endTimeMillis)290     public void rewriteSingle(@NonNull Rewriter rewriter, long startTimeMillis, long endTimeMillis)
291             throws IOException {
292         final FileInfo info = new FileInfo(mPrefix);
293 
294         info.startMillis = startTimeMillis;
295         info.endMillis = endTimeMillis;
296         rewriteSingle(rewriter, info.build());
297     }
298 
299     /**
300      * Read any rotated data that overlap the requested time range.
301      */
readMatching(Reader reader, long matchStartMillis, long matchEndMillis)302     public void readMatching(Reader reader, long matchStartMillis, long matchEndMillis)
303             throws IOException {
304         final FileInfo info = new FileInfo(mPrefix);
305         for (String name : mBasePath.list()) {
306             if (!info.parse(name)) continue;
307 
308             // read file when it overlaps
309             if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) {
310                 if (LOGD) Log.d(TAG, "reading matching " + name);
311 
312                 final File file = new File(mBasePath, name);
313                 readFile(file, reader);
314             }
315         }
316     }
317 
318     /**
319      * Return the currently active file, which may not exist yet.
320      */
getActiveName(long currentTimeMillis)321     private String getActiveName(long currentTimeMillis) {
322         String oldestActiveName = null;
323         long oldestActiveStart = Long.MAX_VALUE;
324 
325         final FileInfo info = new FileInfo(mPrefix);
326         for (String name : mBasePath.list()) {
327             if (!info.parse(name)) continue;
328 
329             // pick the oldest active file which covers current time
330             if (info.isActive() && info.startMillis < currentTimeMillis
331                     && info.startMillis < oldestActiveStart) {
332                 oldestActiveName = name;
333                 oldestActiveStart = info.startMillis;
334             }
335         }
336 
337         if (oldestActiveName != null) {
338             return oldestActiveName;
339         } else {
340             // no active file found above; create one starting now
341             info.startMillis = currentTimeMillis;
342             info.endMillis = Long.MAX_VALUE;
343             return info.build();
344         }
345     }
346 
347     /**
348      * Examine all files managed by this rotator, renaming or deleting if their
349      * age matches the configured thresholds.
350      */
maybeRotate(long currentTimeMillis)351     public void maybeRotate(long currentTimeMillis) {
352         final long rotateBefore = currentTimeMillis - mRotateAgeMillis;
353         final long deleteBefore = currentTimeMillis - mDeleteAgeMillis;
354 
355         final FileInfo info = new FileInfo(mPrefix);
356         String[] baseFiles = mBasePath.list();
357         if (baseFiles == null) {
358             return;
359         }
360 
361         for (String name : baseFiles) {
362             if (!info.parse(name)) continue;
363 
364             if (info.isActive()) {
365                 if (info.startMillis <= rotateBefore) {
366                     // found active file; rotate if old enough
367                     if (LOGD) Log.d(TAG, "rotating " + name);
368 
369                     info.endMillis = currentTimeMillis;
370 
371                     final File file = new File(mBasePath, name);
372                     final File destFile = new File(mBasePath, info.build());
373                     file.renameTo(destFile);
374                 }
375             } else if (info.endMillis <= deleteBefore) {
376                 // found rotated file; delete if old enough
377                 if (LOGD) Log.d(TAG, "deleting " + name);
378 
379                 final File file = new File(mBasePath, name);
380                 file.delete();
381             }
382         }
383     }
384 
readFile(File file, Reader reader)385     private static void readFile(File file, Reader reader) throws IOException {
386         final FileInputStream fis = new FileInputStream(file);
387         final BufferedInputStream bis = new BufferedInputStream(fis);
388         try {
389             reader.read(bis);
390         } finally {
391             IoUtils.closeQuietly(bis);
392         }
393     }
394 
writeFile(File file, Writer writer)395     private static void writeFile(File file, Writer writer) throws IOException {
396         final FileOutputStream fos = new FileOutputStream(file);
397         final BufferedOutputStream bos = new BufferedOutputStream(fos);
398         try {
399             writer.write(bos);
400             bos.flush();
401         } finally {
402             try {
403                 fos.getFD().sync();
404             } catch (IOException e) {
405             }
406             IoUtils.closeQuietly(bos);
407         }
408     }
409 
rethrowAsIoException(Throwable t)410     private static IOException rethrowAsIoException(Throwable t) throws IOException {
411         if (t instanceof IOException) {
412             throw (IOException) t;
413         } else {
414             throw new IOException(t.getMessage(), t);
415         }
416     }
417 
418     /**
419      * Details for a rotated file, either parsed from an existing filename, or
420      * ready to be built into a new filename.
421      */
422     private static class FileInfo {
423         public final String prefix;
424 
425         public long startMillis;
426         public long endMillis;
427 
FileInfo(String prefix)428         public FileInfo(String prefix) {
429             this.prefix = Objects.requireNonNull(prefix);
430         }
431 
432         /**
433          * Attempt parsing the given filename.
434          *
435          * @return Whether parsing was successful.
436          */
parse(String name)437         public boolean parse(String name) {
438             startMillis = endMillis = -1;
439 
440             final int dotIndex = name.lastIndexOf('.');
441             final int dashIndex = name.lastIndexOf('-');
442 
443             // skip when missing time section
444             if (dotIndex == -1 || dashIndex == -1) return false;
445 
446             // skip when prefix doesn't match
447             if (!prefix.equals(name.substring(0, dotIndex))) return false;
448 
449             try {
450                 startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex));
451 
452                 if (name.length() - dashIndex == 1) {
453                     endMillis = Long.MAX_VALUE;
454                 } else {
455                     endMillis = Long.parseLong(name.substring(dashIndex + 1));
456                 }
457 
458                 return true;
459             } catch (NumberFormatException e) {
460                 return false;
461             }
462         }
463 
464         /**
465          * Build current state into filename.
466          */
build()467         public String build() {
468             final StringBuilder name = new StringBuilder();
469             name.append(prefix).append('.').append(startMillis).append('-');
470             if (endMillis != Long.MAX_VALUE) {
471                 name.append(endMillis);
472             }
473             return name.toString();
474         }
475 
476         /**
477          * Test if current file is active (no end timestamp).
478          */
isActive()479         public boolean isActive() {
480             return endMillis == Long.MAX_VALUE;
481         }
482     }
483 }
484